Modular Monoliths with NestJS: Scalable Architecture Without the Microservice Tax

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

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 Module consumes all available RAM, the Payment Module crashes with it.
  • Resource Contention: All modules share the same CPU pool. You cannot easily give the Search Module more 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.

Share: