The Modular Monolith: A Practical Middle Ground
Why jumping straight to microservices might be unnecessary — and how modular monoliths give us structure without the operational overhead.
I've watched teams break a perfectly functional application into a dozen services, then spend months debugging issues that did not exist before. Most applications do not need microservices. What they need is structure. A modular monolith provides that without the operational overhead of distributed systems.
What a Modular Monolith Is
A modular monolith is a single deployable application where the code is organized into well-defined, loosely coupled modules — each with clear boundaries, its own internal logic, and explicit interfaces for communication with other modules.
Key properties:
- Single deployment unit — one build, one deploy, one runtime
- Strong module boundaries — modules cannot reach into each other's internals
- Explicit contracts — modules communicate through defined interfaces
- Independent data ownership — each module owns its data, even if they share a database
Why Not Microservices
Distributed systems trade one set of problems for a larger, more expensive set:
- Network latency between every service call
- Distributed transactions
- Service discovery and load balancing
- Independent deployment pipelines for each service
- Monitoring and tracing across service boundaries
- Data consistency issues
A modular monolith gives us the organizational benefits — clear ownership, separated concerns, independent development — without the operational complexity.
And here is the part that is easy to miss: a well-structured modular monolith is far easier to split into microservices later than a tangled monolith. We are not closing any doors — we are buying ourselves time to learn where the real boundaries are.
Project Structure
For an e-commerce platform, instead of one flat directory of controllers, services, and models, organize by domain:
src/
├── modules/
│ ├── catalog/
│ │ ├── catalog.module.ts
│ │ ├── catalog.service.ts
│ │ ├── catalog.repository.ts
│ │ └── catalog.types.ts
│ ├── orders/
│ │ ├── orders.module.ts
│ │ ├── orders.service.ts
│ │ ├── orders.repository.ts
│ │ └── orders.types.ts
│ ├── payments/
│ │ ├── payments.module.ts
│ │ ├── payments.service.ts
│ │ ├── payments.repository.ts
│ │ └── payments.types.ts
│ └── users/
│ ├── users.module.ts
│ ├── users.service.ts
│ ├── users.repository.ts
│ └── users.types.ts
├── shared/
│ ├── events.ts
│ └── types.ts
└── app.tsEach module exposes a public API and nothing else leaks out.
Enforcing Module Boundaries
Boundaries that are not enforced do not exist. Each module should export only what it wants to expose:
// modules/catalog/catalog.module.ts
import { CatalogService } from './catalog.service';
import { CatalogRepository } from './catalog.repository';
const repository = new CatalogRepository();
const service = new CatalogService(repository);
// This is the public API. Nothing else leaves this module.
export const catalogModule = {
getProduct: (id: string) => service.getProductById(id),
listProducts: (filters: ProductFilters) => service.listProducts(filters),
onProductUpdated: service.productUpdatedEvent,
};
export type { Product, ProductFilters } from './catalog.types';The orders module can use catalogModule.getProduct() but cannot import CatalogRepository directly.
We can enforce this with ESLint rules to prevent cross-module internal imports:
// eslint.config.js
export default [
{
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['*/modules/*/!(*.module|*.types)'],
message: 'Import from the module file, not internal files.',
},
],
}],
},
},
];Communication Between Modules
Modules need to communicate. Use direct calls for synchronous queries and events for notifications.
Direct calls for when we need a response:
// modules/orders/orders.service.ts
import { catalogModule } from '../catalog/catalog.module';
class OrdersService {
async createOrder(userId: string, productId: string, quantity: number) {
const product = await catalogModule.getProduct(productId);
if (!product) {
throw new Error(`Product ${productId} not found`);
}
return this.repository.create({
userId,
productId,
quantity,
totalPrice: product.price * quantity,
});
}
}Events for when a module needs to notify others without knowing who is listening:
// shared/events.ts
type EventHandler<T> = (payload: T) => void | Promise<void>;
export class EventBus {
private handlers = new Map<string, EventHandler<any>[]>();
on<T>(event: string, handler: EventHandler<T>) {
const existing = this.handlers.get(event) || [];
this.handlers.set(event, [...existing, handler]);
}
async emit<T>(event: string, payload: T) {
const handlers = this.handlers.get(event) || [];
await Promise.all(handlers.map((h) => h(payload)));
}
}
export const eventBus = new EventBus();// modules/orders/orders.service.ts
import { eventBus } from '../../shared/events';
// After creating an order:
await eventBus.emit('order.created', { orderId, userId, productId, quantity });// modules/payments/payments.module.ts
import { eventBus } from '../../shared/events';
import { PaymentsService } from './payments.service';
const service = new PaymentsService();
eventBus.on('order.created', async (order) => {
await service.initiatePayment(order);
});The orders module does not know that payments is listening. When we eventually need to extract payments into its own service, we swap the in-process event bus for a message broker — the module's internal code does not change.
Data Ownership Without Separate Databases
Use schema separation or table-level ownership rules:
// modules/catalog/catalog.repository.ts
class CatalogRepository {
// This module owns these tables. No other module touches them.
private readonly TABLES = {
products: 'catalog_products',
categories: 'catalog_categories',
} as const;
async getById(id: string): Promise<Product | null> {
const row = await db.query(
`SELECT * FROM ${this.TABLES.products} WHERE id = $1`,
[id]
);
return row ? this.toProduct(row) : null;
}
}The convention: prefix tables with the module name. The catalog module owns catalog_* tables, orders owns orders_* tables. If one module needs data from another, it goes through the module's public API — never through a direct database query.
When to Move to Microservices
Legitimate reasons to extract a module into a separate service:
- Independent scaling — one module genuinely needs significantly more compute than the rest
- Different runtime requirements — a module needs a GPU, a different language, or a different deployment cadence
- Team autonomy at scale — there are many engineers and coordination overhead is the bottleneck
- Fault isolation — a module's failure is cascading and crashing unrelated functionality
If we have done the modular monolith work, extraction is straightforward. The module already has a defined interface. We put a network boundary where the function call boundary was, swap the event bus for a message queue, and we are most of the way there.
What I'd Recommend
Start with a modular monolith. Be disciplined about boundaries from day one. Use linting and code review to enforce them. Build an event system early — it is cheap and it pays off whether we stay monolithic or not.
Resist the urge to prematurely distribute. Every network hop we add is a new failure mode, a new latency source, and a new thing to monitor. Earn the complexity by proving it is needed.