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
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
}
}
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 │
└─────────────────┘
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();
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);
}
}
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 {}
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 }),
);
}
}
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 {}
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)