- Published on
The Modular Monolith — All the Benefits of Microservices Without the Distributed Systems Tax
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
The microservices hype has cooled, and many teams are rediscovering that a well-structured monolith can be superior to a dozen half-baked services. A modular monolith applies microservices principles—bounded contexts, independent teams, clean interfaces—while retaining simplicity.
- Bounded Contexts as Modules, Not Services
- Module Interface Contracts and No Cross-DB Access
- Module Directory Structure
- Shared Kernel for Cross-Cutting Concerns
- Enforcing Module Boundaries With ESLint Rules
- Integration Between Modules: Sync vs Event
- Extracting a Module to a Microservice
- Testing Modules in Isolation
- Checklist
- Conclusion
Bounded Contexts as Modules, Not Services
Domain-Driven Design's bounded context is a natural module boundary. Instead of breaking into separate deployments, keep them as distinct directories within one codebase.
// src/modules/auth/
// src/modules/orders/
// src/modules/payments/
// No direct imports across modules. Only through published interfaces.
import type { AuthTokenPayload } from './modules/auth/interfaces'
// Each module owns its domain logic completely
export namespace OrdersModule {
export interface CreateOrderRequest {
userId: string
items: Array<{ productId: string; quantity: number }>
}
export interface OrderCreatedEvent {
orderId: string
userId: string
total: number
timestamp: Date
}
export class Order {
constructor(
public id: string,
public userId: string,
public status: 'pending' | 'confirmed' | 'shipped'
) {}
confirm(): OrderCreatedEvent {
this.status = 'confirmed'
return { orderId: this.id, userId: this.userId, total: 0, timestamp: new Date() }
}
}
}
Module Interface Contracts and No Cross-DB Access
Each module defines explicit interfaces. Modules never access another module's database directly.
// src/modules/auth/index.ts - the published interface
export interface AuthService {
validateToken(token: string): Promise<AuthTokenPayload | null>
refreshToken(refreshToken: string): Promise<string>
}
export interface UserRepository {
findById(userId: string): Promise<User | null>
save(user: User): Promise<void>
}
// src/modules/orders/order-service.ts
import type { AuthService } from '../auth'
export class OrderService {
constructor(
private authService: AuthService,
private repository: OrderRepository
) {}
async createOrder(token: string, request: CreateOrderRequest): Promise<Order> {
const payload = await this.authService.validateToken(token)
if (!payload) throw new Error('Invalid token')
const order = new Order(crypto.randomUUID(), payload.userId, 'pending')
await this.repository.save(order)
return order
}
}
// Bad: Modules NEVER do this
// const user = await db.query('SELECT * FROM auth.users WHERE id = ?')
Module Directory Structure
Organize each module with a standard structure that makes boundaries obvious:
src/modules/orders/
├── index.ts (public interface exports)
├── entities/
│ ├── order.ts (domain entity)
│ └── line-item.ts
├── services/
│ ├── order-service.ts (use cases)
│ └── shipping-service.ts
├── repositories/
│ ├── order-repository.ts (interface definition)
│ └── order-repository.postgres.ts (implementation)
├── events/
│ └── order-created.ts
├── adapters/
│ └── stripe-payment-adapter.ts (external integrations)
└── __tests__/
├── order-service.test.ts
└── fixtures.ts
Each module's index.ts explicitly exports only what's public:
// src/modules/orders/index.ts
export type { Order, CreateOrderRequest } from './entities/order'
export { OrderService } from './services/order-service'
export type { OrderRepository } from './repositories/order-repository'
export type { OrderCreatedEvent } from './events/order-created'
Shared Kernel for Cross-Cutting Concerns
Some code naturally spans all modules: logging, metrics, error handling. Keep this in a shared folder:
// src/shared/logger.ts
export class Logger {
info(module: string, message: string, context?: Record<string, any>) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
module,
message,
...context
}))
}
error(module: string, error: Error, context?: Record<string, any>) {
console.error(JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
module,
message: error.message,
stack: error.stack,
...context
}))
}
}
// src/shared/errors.ts
export class DomainError extends Error {
constructor(message: string, public code: string) {
super(message)
this.name = 'DomainError'
}
}
export class ValidationError extends DomainError {
constructor(message: string) {
super(message, 'VALIDATION_ERROR')
}
}
Enforcing Module Boundaries With ESLint Rules
Define boundaries in eslintrc to prevent unauthorized imports:
// .eslintrc.json
{
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
"src/modules/*/repositories/*.postgres.ts",
"src/modules/*/adapters/*.ts"
],
"message": "Import adapters and repositories only through module index"
}
]
},
"overrides": [
{
"files": ["src/modules/orders/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": ["src/modules/!(orders|shared)/**"],
"message": "Orders module can only import from its own module or shared"
}
]
}
}
]
}
Integration Between Modules: Sync vs Event
Modules communicate via two patterns:
Synchronous: Direct service calls for immediate consistency:
// OrderService needs real-time payment status
export class OrderService {
constructor(
private paymentService: PaymentService,
private repository: OrderRepository
) {}
async createOrder(request: CreateOrderRequest): Promise<Order> {
const order = new Order(crypto.randomUUID(), request.userId, 'pending')
await this.repository.save(order)
try {
const payment = await this.paymentService.charge(order.id, request.total)
order.confirmPayment(payment.id)
await this.repository.save(order)
return order
} catch (error) {
order.markFailed()
await this.repository.save(order)
throw error
}
}
}
Asynchronous: Events for loose coupling:
// src/shared/event-bus.ts
export interface DomainEvent {
eventType: string
aggregateId: string
timestamp: Date
}
export class InProcessEventBus {
private handlers: Map<string, Array<(event: DomainEvent) => Promise<void>>> = new Map()
subscribe(eventType: string, handler: (event: DomainEvent) => Promise<void>) {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, [])
}
this.handlers.get(eventType)!.push(handler)
}
async publish(event: DomainEvent) {
const handlers = this.handlers.get(event.eventType) || []
await Promise.allSettled(handlers.map(h => h(event)))
}
}
// Order module publishes events
export class OrderService {
constructor(private eventBus: InProcessEventBus, private repository: OrderRepository) {}
async createOrder(request: CreateOrderRequest): Promise<Order> {
const order = new Order(crypto.randomUUID(), request.userId, 'pending')
await this.repository.save(order)
await this.eventBus.publish({
eventType: 'OrderCreated',
aggregateId: order.id,
timestamp: new Date()
})
return order
}
}
// Notifications module listens
export class NotificationListener {
constructor(private eventBus: InProcessEventBus, private emailSender: EmailSender) {
this.eventBus.subscribe('OrderCreated', this.onOrderCreated.bind(this))
}
private async onOrderCreated(event: DomainEvent) {
await this.emailSender.send({
to: 'customer@example.com',
subject: 'Order Confirmed',
body: `Your order ${event.aggregateId} is confirmed`
})
}
}
Extracting a Module to a Microservice
When a module is large enough to warrant its own service, the transition is straightforward because boundaries already exist:
// Before: All in one process
const authService = new AuthService(authRepository)
const orderService = new OrderService(orderRepository, authService)
// After: Orders is now a separate service
// orderService.ts still imports the same interface
import type { AuthService } from '@mycompany/auth-service-client'
const authServiceClient: AuthService = new AuthServiceClient('http://auth-service:3000')
const orderService = new OrderService(orderRepository, authServiceClient)
The domain logic doesn't change. Only the transport layer changes.
Testing Modules in Isolation
Each module is tested independently with in-memory implementations:
// src/modules/orders/__tests__/order-service.test.ts
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map()
async save(order: Order): Promise<void> {
this.orders.set(order.id, order)
}
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) || null
}
}
class FakeAuthService implements AuthService {
async validateToken(token: string): Promise<AuthTokenPayload | null> {
return token === 'valid' ? { userId: 'user123', exp: Date.now() + 3600000 } : null
}
}
describe('OrderService', () => {
let service: OrderService
let repository: InMemoryOrderRepository
let authService: FakeAuthService
beforeEach(() => {
repository = new InMemoryOrderRepository()
authService = new FakeAuthService()
service = new OrderService(repository, authService)
})
it('should create order for valid token', async () => {
const order = await service.createOrder('valid', {
userId: 'user123',
items: [{ productId: 'p1', quantity: 2 }]
})
expect(order.status).toBe('pending')
expect(order.userId).toBe('user123')
})
it('should reject invalid token', async () => {
expect(() =>
service.createOrder('invalid', { userId: 'user123', items: [] })
).rejects.toThrow('Invalid token')
})
})
Checklist
- Each module has a distinct
src/modules/moduleNamedirectory - Module boundaries enforced via ESLint no-restricted-imports
- All cross-module communication goes through published interfaces in
index.ts - Modules never access other modules' databases directly
- Cross-cutting concerns isolated in
src/shared - Synchronous calls used only when immediate consistency needed
- Asynchronous events used for notifications and non-critical updates
- Each module tested independently with fake/in-memory implementations
- Clear migration path to microservices when needed
Conclusion
A modular monolith gives you the flexibility of microservices—team ownership, independent testing, isolated deployment considerations—without the operational burden. Start with modules. Graduate to services only when you have evidence (traffic, team size, independent scaling needs) that the cost is justified. Many systems never need to cross that line.