The 2 AM Microservices Post-Mortem
It was 2 AM on a Tuesday when my phone buzzed incessantly. PagerDuty reported a 500 error spike in our checkout flow, effectively halting all revenue. After forty minutes of frantic digging through distributed logs across six different repositories, I found the culprit. The Discount Service failed to authenticate with the User Service due to a transient 503 error in our service mesh. We were managing fifteen microservices for an application with fewer than 500 concurrent users.
We had over-engineered ourselves into a corner. By chasing the microservices hype, we traded simple code for expensive, fragile infrastructure. That night made one thing clear: most teams don’t need independent deployment units; they need enforced boundaries. This realization led our team back to the Modular Monolith.
Choosing Your Architecture Wisely
To understand why this approach works, look at the spectrum of backend design. On one end sits the Traditional Monolith, often called the “Big Ball of Mud.” Here, everything is tangled. Changing a single User entity property might break the Invoice generator because they share the same database context and global namespace.
On the opposite end are Microservices. While they offer total isolation, they demand a heavy toll. You’ll face deployment complexity, network latency, and the nightmare of data consistency. Managing Sagas and distributed transactions is a full-time job in itself.
The Modular Monolith offers a pragmatic middle ground. You maintain a single codebase and one deployment unit while enforcing strict logical separation. Each module remains self-contained. If a specific feature—like image processing—eventually requires extreme scaling, you can extract that module into a microservice in an afternoon rather than months. The boundaries are already there.
Pros and Cons: A Reality Check
I’ve deployed this architecture in production environments ranging from startups to mid-sized enterprises. The results are usually stable, but it isn’t a magic fix for every problem. You must weigh the trade-offs.
The Benefits
- Streamlined Deployment: You manage one CI/CD pipeline and one Docker image. A single GitHub Action can deploy your entire stack in under three minutes.
- Raw Performance: Modules communicate via in-memory function calls. This eliminates the 20ms–50ms overhead typical of HTTP or gRPC internal calls.
- Superior Developer Experience: Your team can run the entire system on a standard 16GB RAM laptop. No more spinning up twenty containers just to fix a CSS bug.
- Type Safety: The TypeScript compiler acts as your first line of defense. It catches breaking changes across modules instantly during development.
The Drawbacks
- Shared Fate: If a memory leak in the
Reporting Moduleconsumes all available RAM, thePayment Modulecrashes with it. - Resource Contention: All modules share the same CPU pool. You cannot easily give the
Search Modulemore compute power without scaling the entire application.
The Blueprint for NestJS Success
NestJS is designed for this pattern. Its module system provides the perfect framework for drawing lines in the sand. However, the standard nest generate module command is just a starting point. To prevent “spaghetti imports,” you need a structured directory strategy.
Here is a folder structure optimized for a production-grade modular monolith:
src/
├── modules/
│ ├── orders/
│ │ ├── domain/ (Business logic)
│ │ ├── infrastructure/ (DB schemas, Repositories)
│ │ ├── presentation/ (Controllers)
│ │ ├── orders.module.ts
│ │ └── index.ts (The Public API)
│ ├── users/
│ └── payments/
├── common/ (Shared decorators and filters)
└── main.ts
The index.ts file in each module is your gatekeeper. It acts as the “Public API.” Other modules must only import what you explicitly export here. If a developer reaches deep into ../users/infrastructure/user.repository.ts, they’ve violated the architecture and created technical debt.
Decoupling with Event-Driven Logic
The fastest way to ruin a monolith is by allowing Module A to call Module B’s private services. This creates tight coupling that makes future changes impossible. Instead, use internal events for cross-module communication.
1. Install the Event Emitter
NestJS provides a lightweight, in-memory event bus:
bash
npm install --save @nestjs/event-emitter
2. Register the Module
typescript
// app.module.ts
@Module({
imports: [
EventEmitterModule.forRoot(),
UsersModule,
OrdersModule,
],
})
export class AppModule {}
3. Emitting Events
When a user registers, you might need to send a welcome email and initialize their billing profile. Instead of the UsersService depending on three other services, it simply broadcasts a message.
typescript
@Injectable()
export class UsersService {
constructor(private eventEmitter: EventEmitter2) {}
async create(dto: CreateUserDto) {
const newUser = await this.userRepo.save(dto);
this.eventEmitter.emit('user.created', {
userId: newUser.id,
email: newUser.email,
});
return newUser;
}
}
4. Handling the Event
The EmailModule listens for this event. The UsersModule remains completely unaware of who is listening, keeping the logic decoupled and clean.
typescript
@Injectable()
export class NotificationsListener {
@OnEvent('user.created')
handleUserCreatedEvent(payload: UserCreatedPayload) {
// Logic to trigger SendGrid or Amazon SES
console.log(`Queueing welcome email for: ${payload.email}`);
}
}
Automating Boundary Enforcement
Manual code reviews eventually miss things. To maintain modularity over years of development, use tools like Nx or Sheriff. These allow you to define hard dependency rules. You can, for instance, set a rule stating: “The ‘Payments’ module can never import from the ‘Admin’ module.”
If a developer accidentally crosses that line, the linter fails. The PR cannot be merged. This automation ensures your architecture stays clean even as the team grows from two developers to twenty.
Final Thoughts
Choosing a Modular Monolith isn’t about being lazy. It is a pragmatic decision to prioritize velocity and maintainability. This approach allows you to move fast and keep your code clean. You can postpone the massive overhead of microservices until your traffic—and your DevOps budget—actually justifies the move.
Before you start your next project, don’t worry about how many services to create. Focus on how to define your boundaries. Your future self will appreciate the sleep.

