DEV Community

Geampiere Jaramillo
Geampiere Jaramillo

Posted on

Modular Monolith vs Microservices in NestJS

NestJS was deliberately designed so you can start simple and grow without rewriting. Here's how to take full advantage of that.

When you start a new backend project, one of the first debates that comes up is: monolith or microservices?


What are we actually talking about?

Before comparing, let's align on definitions:

  • Modular Monolith: a single application deployed as one unit, but with code organized into modules with clear boundaries. One module per business domain.
  • Microservices: multiple independent applications that communicate over the network (HTTP, messaging, gRPC). Each service is deployed, scaled, and updated autonomously.

The most common misconception is thinking "monolith" means "messy code." It doesn't. A monolith can be perfectly structured — and in NestJS, that's the norm, not the exception.


Modular Monolith with NestJS

NestJS was built for the modular monolith. Its module system forces you to think in terms of domains from day one.

Typical folder structure

src/
├── app.module.ts
├── users/
│   ├── users.module.ts
│   ├── users.controller.ts
│   ├── users.service.ts
│   └── entities/user.entity.ts
├── orders/
│   ├── orders.module.ts
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   └── entities/order.entity.ts
└── payments/
    ├── payments.module.ts
    ├── payments.service.ts
    └── dto/payment.dto.ts
Enter fullscreen mode Exit fullscreen mode

Each domain lives in its own module with its controllers, services, and entities. Communication between modules happens through dependency injection — no network involved.

Inter-module communication

// orders.module.ts — imports UsersModule to use its service
@Module({
  imports: [UsersModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

// orders.service.ts
@Injectable()
export class OrdersService {
  constructor(
    private readonly usersService: UsersService,
  ) {}

  async createOrder(userId: string, items: OrderItem[]) {
    // Direct in-memory call — no network latency
    const user = await this.usersService.findOne(userId);
    if (!user) throw new NotFoundException('User not found');
    // ... business logic
  }
}
Enter fullscreen mode Exit fullscreen mode

The call to usersService.findOne() is an in-memory function call. No serialization, no latency, no network failure points.

Pros and cons

✅ Advantages:

  • Fast initial development, single repository
  • Simple local debugging — one process, one log
  • Zero latency between modules
  • Native database transactions (no Saga needed)
  • Single CI/CD pipeline

⚠️ Disadvantages:

  • Scales as a whole unit (you can't scale just the payments module)
  • Slower builds and deploys as it grows
  • An uncaught failure can bring down the entire process

Microservices with NestJS

NestJS has first-class support for microservices through @nestjs/microservices, supporting TCP, Redis, RabbitMQ, Kafka, gRPC, NATS, and more.

General architecture

                  ┌─────────────────┐
                  │   API Gateway   │
                  └────────┬────────┘
           ┌───────────────┼───────────────┐
           ▼               ▼               ▼
   ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
   │users-service │ │orders-service│ │payments-svc  │
   │   :3001      │ │   :3002      │ │   :3003      │
   └──────────────┘ └──────────────┘ └──────────────┘
           │               │               │
           └───────────────┴───────────────┘
                           │
                  ┌────────▼────────┐
                  │  RabbitMQ/Kafka │
                  └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Setting up a microservice

// users-service/main.ts
import { NestFactory } from '@nestjs/core';
import { Transport, MicroserviceOptions } from '@nestjs/microservices';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    AppModule,
    {
      transport: Transport.RMQ,
      options: {
        urls: ['amqp://localhost:5672'],
        queue: 'users_queue',
        queueOptions: { durable: false },
      },
    },
  );
  await app.listen();
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

Inter-service communication

// orders-service: calls users-service via MessagePattern
@Injectable()
export class OrdersService implements OnModuleInit {
  private usersClient: ClientProxy;

  onModuleInit() {
    this.usersClient = this.clientProxy.get('USERS_SERVICE');
  }

  async createOrder(userId: string) {
    // Async call to the users microservice
    const user = await firstValueFrom(
      this.usersClient.send({ cmd: 'get_user' }, { userId }),
    );
    // ... continue with the order
  }
}

// users-service: exposes the MessagePattern
@Controller()
export class UsersController {
  @MessagePattern({ cmd: 'get_user' })
  getUser(data: { userId: string }) {
    return this.usersService.findOne(data.userId);
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros and cons

✅ Advantages:

  • Independent scaling per service
  • Independent deployment — the payments team deploys without coordinating with users
  • Fault isolation
  • Autonomous, parallel teams

⚠️ Disadvantages:

  • High operational complexity (orchestration, service discovery, distributed observability)
  • Network latency between services
  • Complex distributed transactions — you need the Saga pattern
  • Infrastructure overhead: Kubernetes, message brokers, tracing

Head-to-head comparison

Criteria Modular Monolith Microservices
Initial development speed ✅ High ❌ Low
Independent scaling ❌ All or nothing ✅ Per service
Internal latency ✅ Zero (in-memory) ❌ Network + serialization
ACID transactions ✅ Native ❌ Saga / 2PC required
Local testing ✅ Simple ⚠️ Requires orchestration
Learning curve ✅ Low ❌ High
Fault isolation ❌ One crash affects everything ✅ Contained failures
Observability ✅ Single log stream ❌ Requires distributed tracing
Ideal for small teams ✅ Yes ❌ Not recommended

When to use each one?

Choose a modular monolith if…

  • The team has fewer than 10 engineers
  • The business domain is still evolving (you don't know the boundaries yet)
  • You need to ship fast — MVP, startup, market validation
  • You don't have a dedicated DevOps/SRE team
  • Modules share a lot of data with each other
  • Infrastructure costs are a real constraint

Choose microservices if…

  • The system has been in production for a while and domain boundaries are very clear
  • Different teams need to deploy without coordinating with each other
  • Some components have radically different scaling demands
  • You have compliance requirements that require data isolation
  • You have a dedicated DevOps/SRE team with distributed systems experience

Classic antipattern — Distributed Monolith: starting with microservices without knowing the domain boundaries results in services that call each other synchronously for every operation, distributed transactions that can't fail, and deployments that must be coordinated just like in a monolith. The worst of both worlds. Martin Fowler documents this extensively.


Migration strategy: from monolith to microservice

NestJS's biggest advantage is that the same modular structure you use in the monolith is the foundation for extracting microservices. The safest strategy is the Strangler Fig pattern: you extract modules incrementally without stopping the system.

Step 1 — Start with a well-modularized monolith

// app.module.ts
@Module({
  imports: [
    UsersModule,
    OrdersModule,
    PaymentsModule,
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Abstract communication with interfaces

The key is that service consumers shouldn't know whether they're talking to a local or remote implementation.

// Transport-agnostic interface
export interface IUsersService {
  findOne(id: string): Promise<User>;
  create(dto: CreateUserDto): Promise<User>;
}

// Local implementation (monolith)
@Injectable()
export class UsersService implements IUsersService { ... }

// Remote implementation (microservice) — same contract
@Injectable()
export class UsersClientService implements IUsersService {
  findOne(id: string) {
    return firstValueFrom(
      this.client.send({ cmd: 'get_user' }, { id }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Swap in the module without touching consumers

@Module({
  providers: [
    {
      provide: 'IUsersService',
      // Monolith:  useClass: UsersService
      useClass: UsersClientService, // ← now it's a microservice
    },
  ],
})
export class OrdersModule {}
Enter fullscreen mode Exit fullscreen mode

OrdersService keeps injecting IUsersService without changing a single line. You only change the provider in the module.

With this pattern you can extract services gradually, validate in production, and roll back if something goes wrong — no big bang migration needed.


Conclusion

There is no universally superior architecture. The right question isn't "microservices or monolith?" but "what architecture solves the problems I have today?"

The modular monolith is the right choice for most projects in their early stages. It lets you move fast, maintain coherence, and reduce the team's cognitive load.

Microservices are a solution to specific organizational and scaling problems — not a goal in themselves. Introduce that complexity only when the pain of not having it outweighs the cost of operating it.

And NestJS, with its modular architecture, native support for multiple transports, and the abstraction pattern shown here, gives you the luxury of starting simple and evolving without rewriting. That's its greatest advantage.


Golden rule: "Start with the cleanest modular monolith you can build. Your future microservices are already defined in your modules."


Have you migrated from a monolith to microservices in NestJS? I'd love to read your story in the comments.

Top comments (0)