Any organization that designs a system will produce a design whose structure is a copy of the organization's communication structure. - Melvin Conway
Every growing Node.js backend arrives at the same fork in the road. The Express app that started as three routes now has four hundred, a deploy takes twenty minutes because everything ships together, and one team's bad migration takes checkout down for everybody. The instinct is to split it into microservices. That instinct is often right - and just as often it replaces a slow monolith with a distributed system nobody can debug. NestJS matters at exactly this fork, because it is one of the very few Node.js frameworks that ships an opinion about structure: modules, providers, dependency injection, and a transport-agnostic microservice layer where the same handler code can speak TCP, Redis, NATS, RabbitMQ, Kafka, or gRPC.
But NestJS is routinely misunderstood as "Angular for the backend" or "Express with decorators." That framing sells it short and, worse, leads teams into the classic distributed-systems bugs: services that quietly share a database, events processed twice because a broker redelivered them, retries that turn a two-second blip into a full outage, sagas with no compensating action. Nest gives you composition primitives and a clean request lifecycle. It does not give you your service boundaries, your idempotency, or your observability - those are design decisions, and each one has correctness rules that are easy to get wrong.
Key Takeaway
- NestJS is an architecture, not a router - modules, providers, and DI are what make a service testable and splittable. Get the module graph right and the eventual service split is mostly mechanical.
- Start with a modular monolith. A boundary you cannot enforce inside one process will not survive being turned into a network call.
- Choose the communication style deliberately - send() for a request whose answer the caller needs, emit() for a fact other services react to. Confusing the two is how you build a distributed monolith.
- Every service owns its data. A shared database re-couples services at the schema level and silently undoes the entire split.
- Distributed transactions are sagas, not transactions - model compensating actions explicitly, and make every consumer idempotent, because at-least-once is the delivery guarantee you actually get.
- Resilience is code you write, not a property you inherit: timeouts, bounded retries with backoff and jitter, circuit breakers, dead-letter queues, and graceful shutdown.
- Without correlation IDs, distributed tracing, and structured logs, a microservice failure is unreadable. Instrument before you split, not after.
Index
- Introduction
- Understanding the NestJS Architecture Model
- Designing Service Boundaries
- Inter-Service Communication
- Data, Consistency & Transactions
- Resilience, Observability & Operations
- Stats & Interesting Facts
- FAQ
- Conclusion
1. Introduction
NestJS was released in 2017 by Kamil Mysliwiec, who wanted for Node.js what Angular had given the front end: a framework that answers "where does this code go?" before you have written any of it. It is written in TypeScript, it runs on Express by default with a Fastify adapter available, and the HTTP layer is deliberately the least interesting part of it. What Nest actually provides is an inversion-of-control container, a decorator-driven module system, and a request lifecycle with well-defined extension points - guards, interceptors, pipes, and exception filters - so that cross-cutting concerns are written once rather than copy-pasted into every handler.
That structure is precisely what makes Nest a good microservices framework, and the connection is not obvious at first. A microservice split is a dependency-graph problem long before it is an infrastructure problem. If your billing logic reaches directly into the orders table and constructs its own database client, no amount of Docker will separate them. A Nest module, by contrast, has an explicit imports list and an explicit exports list: the surface it consumes and the surface it offers are both declared. A module whose exports are small and whose imports are few is already, in effect, a service - moving it out of the process becomes a change of transport rather than a rewrite.
The failure modes follow from the same place. Nest will happily let you import every module into every other module until the graph is a hairball. It will let you pass a send() call the same way you would call a local function, hiding a network hop behind a method signature that cannot fail locally but absolutely can fail remotely. It will let you emit an event and assume it arrives exactly once. None of these are defects in Nest - they are the ordinary hazards of distributed systems, showing up in a framework that makes distribution easy enough that you reach for it early. This article walks through the architecture model, boundary design, communication patterns, data consistency, and the operational concerns - with concrete, production-shaped code you can adapt.
2. Understanding the NestJS Architecture Model
Before designing a single service, anchor your mental model. A Nest application is not a list of routes with middleware bolted on; it is a graph of modules resolved by a container at boot, plus a well-defined pipeline that every request passes through. Getting these two things right is most of the design work, and almost every "we cannot split this service" conversation traces back to one of them being wrong.
2.1 Modules, Providers & Dependency Injection
A module is a unit of ownership. It declares what it needs (imports), what it builds (providers), what it serves (controllers), and - critically - what it lets anyone else use (exports). Anything not exported is genuinely private, enforced by the container rather than by convention. A provider is anything injectable: a service, a repository, a factory, a value. Dependency injection is what makes the whole thing testable, because a class that receives its collaborators through its constructor can be handed fakes in a unit test and a real client in production without changing a line.
Two rules pay for themselves repeatedly. Inject against an abstraction - an interface plus an injection token - rather than a concrete class, so the implementation can be swapped for a remote client when the module becomes a service. And keep the export list deliberately small: it is the public API of that bounded context, and every extra export is a future coupling you will have to unpick.
// orders.module.ts - the module IS the boundary. Nothing leaks unless exported.
@Module({
imports: [TypeOrmModule.forFeature([Order]), PaymentsClientModule],
controllers: [OrdersController],
providers: [
OrdersService,
{ provide: ORDER_POLICY, useClass: TieredPricingPolicy }, // token, not class
],
exports: [OrdersService], // <- the ONLY public surface of this context
})
export class OrdersModule {}
// Constructor injection: business logic never news-up its collaborators
@Injectable()
export class OrdersService {
constructor(
@InjectRepository(Order) private readonly orders: Repository<Order>,
@Inject(ORDER_POLICY) private readonly policy: OrderPolicy,
private readonly events: EventPublisher,
) {}
}
2.2 The Request Lifecycle: Guards, Interceptors, Pipes & Filters
Every request that reaches a Nest handler passes through a fixed pipeline, and knowing the order is what stops you from putting logic in the wrong place. Middleware runs first and is framework-level. Guards decide whether the request is allowed at all - authentication and authorisation live here, and nowhere else. Interceptors wrap the handler on both sides, which makes them the right home for timeouts, caching, response shaping, and tracing spans. Pipes transform and validate the incoming payload just before the handler receives it. Exception filters catch whatever is thrown and turn it into a response.
The single highest-leverage line in a Nest microservice is a global ValidationPipe with whitelist enabled. It strips properties that are not on your DTO, which means a malformed or hostile payload cannot smuggle fields past your handler - and because the same pipe applies to message handlers as well as HTTP controllers, an event arriving from a broker is validated exactly like a request arriving from the internet.
// main.ts - one global pipe replaces hand-written validation in every handler
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip properties not on the DTO
forbidNonWhitelisted: true, // ...or reject the payload outright
transform: true, // plain JSON -> typed DTO instance
}));
// A cross-cutting concern written once, applied everywhere
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(_ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
timeout(3000),
catchError((err) => throwError(() =>
err instanceof TimeoutError ? new RequestTimeoutException() : err)),
);
}
}
2.3 Configuration as a Boot-Time Contract
A microservice has more configuration than a monolith and far less tolerance for getting it wrong, because a missing variable in one of twelve services fails at an arbitrary time under load rather than at startup. Validate the whole environment at boot with ConfigModule and a schema, so a bad deployment crashes immediately and visibly instead of at three in the morning on the first request that happens to read the missing value. Never read process.env directly from business logic - inject typed configuration, and the same class becomes trivially testable.
// Fail at boot, loudly - not at 3am on the first request that reads the value
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').required(),
DATABASE_URL: Joi.string().uri().required(),
NATS_URL: Joi.string().uri().required(),
HTTP_TIMEOUT_MS: Joi.number().default(3000),
}),
validationOptions: { abortEarly: false }, // report every missing var at once
});
If you can't build a well-structured monolith, what makes you think microservices are the answer? - Simon Brown
3. Designing Service Boundaries
Nest makes it easy to create a service. It does not tell you whether you should. Boundaries are the one decision that is genuinely expensive to reverse, because every wrong boundary becomes a chatty network call, a distributed transaction, or a shared database - and usually all three.
3.1 The Modular Monolith Comes First
Build a modular monolith and extract from it. One deployable, one repository, but strict module boundaries: no module reaches into another module's repositories, every cross-module call goes through an exported service, and no two modules share entity classes. This costs almost nothing and buys you the ability to discover your boundaries with a refactor rather than with a migration. When a module has stopped changing for reasons that belong to other modules, when it has its own scaling profile, or when a separate team owns it, that is the signal to extract it.
Split on ownership, not on nouns. "A service per database table" produces a system where creating an order requires six round trips. The useful question is which pieces of data change together in one transaction and who is accountable when they are wrong - that cluster is a service. If two candidate services need a distributed transaction for their most common operation, they are one service that has been cut in the wrong place.
3.2 Monorepo, Shared Libraries & Contracts
The Nest CLI supports a monorepo layout - multiple apps alongside shared libs - and it is the pragmatic default for a small team. One dependency tree, one CI pipeline, atomic changes across a producer and its consumers. What you share matters far more than where the code lives: share contracts - DTOs, message pattern names, event shapes - and share generic infrastructure such as a logger or a tracing setup. Never share entities, repositories, or business logic. A shared libs/domain becomes exactly the coupling you split the system up to avoid, and every change to it redeploys everything.
apps/
gateway/ # HTTP edge: auth, aggregation, rate limiting
orders/ # owns the orders database
payments/ # owns the payments database
libs/
contracts/ # DTOs + pattern names - the ONLY shared domain surface
observability/ # logger, tracing, correlation id
// libs/contracts/src/orders.contract.ts - both sides import this one file
export const ORDER_PATTERNS = {
create: 'orders.create',
findOne: 'orders.findOne',
} as const;
export const ORDER_EVENTS = {
created: 'order.created.v1', // version the event, not just the endpoint
cancelled: 'order.cancelled.v1',
} as const;
export class OrderCreatedEvent {
@IsUUID() readonly orderId!: string;
@IsUUID() readonly customerId!: string;
@IsInt() readonly totalCents!: number;
@IsISO8601() readonly occurredAt!: string; // stamped by the producer
}
4. Inter-Service Communication
This is where NestJS earns its place. A Nest microservice is the same controllers, the same providers, and the same pipeline as an HTTP application - only the transport differs, and the transport is a configuration object. That symmetry is a genuine gift, and also a trap: because a remote call looks like a local one, it is easy to forget that it can time out, arrive twice, or arrive out of order.
4.1 Transporters & the Microservice Bootstrap
Nest ships transporters for TCP, Redis, NATS, MQTT, RabbitMQ, Kafka, and gRPC, and a custom transporter interface for anything else. Pick on delivery semantics, not on familiarity. TCP is the built-in default and is fine for internal request-response between a handful of services, but it has no broker, so there is no buffering and no redelivery. NATS is excellent for low-latency request-response plus lightweight pub/sub with queue groups. RabbitMQ gives you real queues, per-message acknowledgement, and dead-letter exchanges - the sane default for work that must not be lost. Kafka is a durable, replayable, partitioned log: the right answer for event streaming and for consumers that need to re-read history, and overkill for simple RPC. gRPC is for typed, high-throughput synchronous calls.
A single process can be both an HTTP server and a message consumer - the hybrid application - which is how a gateway usually works and how a service exposes health endpoints while consuming from a broker.
// A pure microservice: same decorators, different transport
const app = await NestFactory.createMicroservice<MicroserviceOptions>(OrdersModule, {
transport: Transport.NATS,
options: {
servers: [process.env.NATS_URL],
queue: 'orders-workers', // queue group => one delivery per group, load balanced
},
});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen();
// Hybrid: HTTP for the outside world, a broker for the inside
const http = await NestFactory.create(AppModule);
http.connectMicroservice<MicroserviceOptions>({
transport: Transport.RMQ,
options: { urls: [process.env.RMQ_URL], queue: 'orders', noAck: false },
});
await http.startAllMicroservices();
await http.listen(3000);
4.2 Request-Response vs Event-Driven
These two are not stylistic alternatives; they encode different couplings. @MessagePattern handles a request-response call: a caller is blocked waiting for your answer, so your latency is their latency and your outage is their outage. @EventPattern handles an event: a statement that something happened, with no reply channel and no waiting caller. On the calling side, send() returns a cold Observable and only dispatches on subscribe - a genuinely common bug is calling send() without subscribing and wondering why nothing happened - while emit() fires and returns.
The default should be events. Every synchronous hop you add multiplies your failure probability and your tail latency; three services at 99.9% availability chained synchronously give you 99.7%. Reserve request-response for a query whose result the caller cannot proceed without, always with an explicit timeout, and let everything downstream of a write - email, analytics, search indexing, recommendations - be a reaction to a published event.
// Handlers: @MessagePattern answers a caller; @EventPattern reacts to a fact
@Controller()
export class OrdersController {
@MessagePattern(ORDER_PATTERNS.findOne) // request-response
findOne(@Payload() dto: FindOrderDto) {
return this.orders.findOne(dto.id);
}
@EventPattern(ORDER_EVENTS.created) // no reply channel
async onOrderCreated(@Payload() evt: OrderCreatedEvent, @Ctx() ctx: RmqContext) {
await this.projections.apply(evt); // MUST be idempotent
ctx.getChannelRef().ack(ctx.getMessage()); // ack only after success
}
}
// Callers: send() WAITS and is cold (nothing happens until subscribe).
const order = await firstValueFrom(
this.client.send<Order>(ORDER_PATTERNS.findOne, { id }).pipe(timeout(2000)),
);
// emit() blocks nobody and expects no reply - the default for anything downstream
this.client.emit(ORDER_EVENTS.created, event);
4.3 gRPC & Contract-First APIs
When two services talk constantly and synchronously, a hand-written JSON DTO shared through a library is a contract only by agreement - nothing stops a producer from shipping a breaking change. gRPC replaces the agreement with an artefact: a .proto file that generates both sides, transmits Protocol Buffers over HTTP/2, and supports streaming in both directions. Nest's gRPC transporter maps @GrpcMethod onto your existing service classes, so adopting it is a controller-layer change rather than an architectural one. Keep the proto files in a shared package, treat field numbers as immutable, and only ever add optional fields - that is the whole of backwards compatibility.
// orders.proto - a contract that compiles. A shared interface only hopes.
syntax = "proto3";
package orders;
service Orders {
rpc FindOne (FindOneRequest) returns (Order) {}
}
message FindOneRequest { string id = 1; }
message Order {
string id = 1;
string customer_id = 2;
int64 total_cents = 3; // field numbers are forever - only ever ADD
}
// orders.controller.ts - the same service class, a different decorator
@GrpcMethod('Orders', 'FindOne')
findOne(data: FindOneRequest): Promise<Order> {
return this.orders.findOne(data.id);
}
4.4 The Gateway & the Aggregation Trap
An API gateway - a Nest HTTP application that fans out to internal services - is the right edge for authentication, rate limiting, request shaping, and a single public surface. Keep it thin. The moment it contains business rules it becomes a monolith with extra network hops, and every team ends up blocked on the same repository. Aggregate with forkJoin or Promise.all so independent calls run concurrently rather than serially, put a timeout on each one, and decide explicitly what a partial failure returns: a degraded response with a missing section is almost always better than a 500.
5. Data, Consistency & Transactions
Splitting the code is straightforward. Splitting the data is where microservice projects actually succeed or fail, because the database is the last place coupling hides and the first place it hurts.
5.1 Database per Service
Each service owns its schema, and no other service reads it - not even for a "quick report," not even read-only. A shared table means you cannot change a column without a cross-team migration, which is precisely the constraint you split the system to escape. When another service needs your data, it either asks you for it or subscribes to your events and keeps its own projection shaped for its own queries. The cost is real: you give up joins and you give up cross-service ACID transactions. The benefit is that you get to deploy independently, which was the entire point.
5.2 Sagas & Compensating Actions
Without a shared transaction, a multi-service operation becomes a saga: a sequence of local transactions where each step has a defined compensating action to undo it. Orchestrated sagas put the sequence in one coordinator - easier to reason about, easier to debug, and the right starting point. Choreographed sagas let each service react to the previous one's event - less coupling, but the flow exists only in the collective imagination of the services and becomes very hard to trace.
The critical discipline is that compensation is not rollback. You cannot un-send an email or un-charge a card without a trace; you send an apology and issue a refund. Model those explicitly as first-class operations, and persist the saga's state so that a coordinator crashing halfway through does not strand the order.
// A saga: local transactions plus explicit compensating actions.
async placeOrder(cmd: PlaceOrderCommand) {
const order = await this.orders.create(cmd); // step 1
try {
await this.payments.charge(order.id, order.totalCents); // step 2
} catch (err) {
await this.orders.markFailed(order.id, 'payment_declined'); // compensate 1
throw err;
}
try {
await this.inventory.reserve(order.id, cmd.items); // step 3
} catch (err) {
await this.payments.refund(order.id); // compensate 2
await this.orders.markFailed(order.id, 'out_of_stock'); // compensate 1
throw err;
}
this.events.emit(ORDER_EVENTS.created, toEvent(order));
}
5.3 The Outbox Pattern & Idempotency
There is one bug that every event-driven system writes at least once: committing a database transaction and then publishing an event. If the process dies in between, the state changed and nobody was told - a silent, permanent inconsistency. The transactional outbox fixes it by writing the event into a table inside the same transaction as the state change, and having a separate relay poll that table and publish. The event can now be published twice, but it can never be lost, and "twice" is a problem you can solve.
You solve it with idempotency. Every broker worth using guarantees at-least-once delivery, which means your consumer will eventually run the same message twice - after a redelivery, a rebalance, or a retry. Give every message a stable identifier produced by the emitter, claim it in a store with a TTL before doing any work, and treat a duplicate as a successful no-op. Idempotency is not an optimisation for busy systems; it is the correctness requirement that makes retries safe at all.
// Outbox: the state change and the event commit together, or neither does.
await this.dataSource.transaction(async (trx) => {
const order = await trx.save(Order, draft);
await trx.save(OutboxMessage, {
id: messageId, // stable id, survives every retry
pattern: ORDER_EVENTS.created,
payload: toEvent(order),
});
});
// A separate relay polls the outbox and publishes: at-least-once, never lost.
// Consumer side: "handled twice" WILL happen. Make it a no-op.
@EventPattern(ORDER_EVENTS.created)
async onCreated(@Payload() evt: OrderCreatedEvent) {
const isNew = await this.dedupe.claim(`order.created:${evt.orderId}`, 86_400);
if (!isNew) return; // already processed - ack and move on
await this.mailer.sendConfirmation(evt);
}
A distributed system is one in which the failure of a computer you didn't even know existed can render your own computer unusable. - Leslie Lamport
6. Resilience, Observability & Operations
A monolith fails all at once and tells you so. A distributed system fails partially, intermittently, and somewhere else - which is why the operational work is not a phase after the build but a property of it. The controls below are what keep a NestJS microservice fleet healthy past launch day.
6.1 Timeouts, Retries & Circuit Breakers
Every remote call needs an explicit timeout, because the default in most clients is to wait forever, and a hung dependency will exhaust your connection pool and take you down with it. Every retry needs a bound, exponential backoff, and jitter - an unbounded bare retry is not resilience, it is a self-inflicted denial of service that keeps a struggling dependency down. Retry only what is safe to retry: idempotent reads always, writes only when they carry an idempotency key.
When a dependency is genuinely down, retrying at all is waste. A circuit breaker - opossum wraps a Nest provider in a few lines - trips after a failure threshold, fails fast for a cooling-off period, then lets a probe through. Pair it with a fallback: cached data, a queued write, or a degraded response. Failing fast with a useful answer beats timing out with none.
// Bounded retry with exponential backoff and jitter - never a bare retry()
this.client.send(PAYMENT_PATTERNS.charge, cmd).pipe(
timeout(2000),
retry({
count: 3,
delay: (_err, n) => timer(Math.min(2 ** n * 100, 2000) + jitterMs()),
}),
catchError(() => throwError(() =>
new ServiceUnavailableException('payments'))),
);
// Circuit breaker: stop hammering a dependency that is already down
const breaker = new CircuitBreaker(chargeFn, {
timeout: 2000,
errorThresholdPercentage: 50,
resetTimeout: 10_000, // cool off, then let one probe through
});
breaker.fallback(() => ({ status: 'deferred' })); // degrade, do not 500
6.2 Health Checks & Graceful Shutdown
Two endpoints, and they are not the same endpoint. Liveness answers "is this process wedged and in need of a restart?" and must not check dependencies - if it does, one slow database restarts your entire fleet. Readiness answers "can I serve traffic right now?" and legitimately checks the database and the broker, so the orchestrator stops routing to a pod that cannot work. @nestjs/terminus implements both.
Graceful shutdown matters more in a consumer than in an HTTP server. On SIGTERM, stop accepting new messages, finish the ones already in flight, then close connections - otherwise every deploy drops whatever was mid-handler. Call enableShutdownHooks() and implement OnApplicationShutdown, and remember that a container's grace period is finite: your drain has to fit inside it.
// Liveness must NOT check dependencies. Readiness must.
@Controller('health')
export class HealthController {
@Get('live')
live() { return this.health.check([]); } // am I wedged?
@Get('ready')
ready() { // can I serve traffic?
return this.health.check([
() => this.db.pingCheck('database', { timeout: 1000 }),
() => this.disk.checkStorage('disk', { thresholdPercent: 0.9, path: '/' }),
]);
}
}
// main.ts
app.enableShutdownHooks();
@Injectable()
export class ConsumerLifecycle implements OnApplicationShutdown {
async onApplicationShutdown(signal?: string) {
await this.consumer.pause(); // stop accepting new messages
await this.inFlight.drain(15_000); // finish what is already running
}
}
6.3 Tracing, Logging & Metrics
In one process a stack trace tells you what happened. Across twelve services it tells you almost nothing, because the cause is three hops upstream. Distributed tracing is the replacement: instrument with OpenTelemetry, propagate context on every hop including broker messages, and you get one waterfall per user action showing exactly which span was slow. Generate a correlation ID at the gateway, put it in an AsyncLocalStorage so it needs no threading through function signatures, attach it to every log line, and forward it as a header or message property on every outbound call.
Log structured JSON, never interpolated strings - pino through nestjs-pino costs little and makes logs queryable. Redact tokens and PII at the logger, not at the call site, because the call site you forget is the one that leaks. For metrics, the four RED/USE signals per service - request rate, error rate, duration percentiles, and queue depth or consumer lag - answer most incident questions before you open a trace. Alert on p99 latency and consumer lag, not on averages: an average hides exactly the tail your users are complaining about.
// Correlation id: created at the edge, carried on every hop, on every log line
@Injectable()
export class CorrelationInterceptor implements NestInterceptor {
constructor(private readonly als: AsyncLocalStorage<Store>) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
const correlationId = req.headers['x-correlation-id'] ?? randomUUID();
return this.als.run({ correlationId }, () => next.handle());
}
}
// tracing.ts - must be imported BEFORE anything else in main.ts
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_URL }),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
6.4 Security & Performance
Authenticate at the edge and authorise everywhere. A gateway-only check means any service that gets reached by another route - a misconfigured ingress, a compromised neighbour - is wide open, so validate a signed token in each service with a guard and never trust a plain userId field in a message payload. Use mTLS or a service mesh for internal traffic that crosses a trust boundary, keep secrets in a secret manager rather than in environment files committed by accident, and set a RateLimit guard at the gateway.
On performance, the framework is rarely your bottleneck - the network and your query patterns are. Swap the Express adapter for Fastify if the HTTP layer measurably matters, batch or pipeline chatty calls instead of looping over send(), and cache read models close to the reader. The most common Nest-specific performance mistake is not a slow framework but an N+1 fan-out: a loop that makes one remote call per item, turning a single request into two hundred.
7. Stats & Interesting Facts
- NestJS was created by Kamil Mysliwiec and first released in 2017. It is written in TypeScript, is platform-agnostic at the HTTP layer, and uses Express by default with an official Fastify adapter available.Source: https://docs.nestjs.com/
- Nest ships seven built-in transporters out of the box - TCP, Redis, NATS, MQTT, RabbitMQ, Kafka, and gRPC - plus a custom transporter interface, so the same handler code can change protocol with a configuration object. Source: https://docs.nestjs.com/microservices/basics
- @nestjs/core is downloaded several million times a week on npm, placing it among the most used server-side TypeScript frameworks in the ecosystem.Source: https://www.npmjs.com/package/@nestjs/core
- The Nest repository has accumulated more than 60,000 GitHub stars, and the framework has appeared consistently among the web technologies tracked in the Stack Overflow Developer Survey. Source: https://github.com/nestjs/nest
- The Nest CLI has a first-class monorepo mode that manages multiple applications and shared libraries in a single project with one tsconfig and one dependency tree. Source: https://docs.nestjs.com/cli/monorepo
- gRPC transmits Protocol Buffers over HTTP/2 with multiplexed streams and binary framing - typically far smaller on the wire than equivalent JSON, which is why it is the usual choice for high-volume internal RPC.Source: https://grpc.io/docs/what-is-grpc/introduction/
- The saga pattern and the transactional outbox are the two canonical answers to "there are no distributed transactions" - both predate microservices by decades and both remain the standard solution. Source: https://microservices.io/patterns/data/saga.html
Microservices are not a goal. They are a trade you make when the cost of coordinating exceeds the cost of distributing. - Backend folk wisdom
8. FAQ
1. Do I actually need microservices, or is a modular monolith enough?
Ans: For most teams, a modular monolith is enough for far longer than they expect. Microservices buy independent deployment, independent scaling, and independent team ownership - and they cost you distributed transactions, network failure modes, and an observability bill you must pay before you can debug anything. If your pain is code organisation, fix the module boundaries; that is free. If your pain is that fifteen engineers cannot ship without coordinating, or that one endpoint needs ten times the hardware of the rest, that is what a split solves.
2. What is the real difference between send() and emit()?
Ans: send() is request-response: it returns a cold Observable, dispatches only on subscription, and a caller is blocked waiting for your reply - so your latency and your availability become theirs. emit() publishes an event with no reply channel and returns immediately. Use send() only when the caller genuinely cannot proceed without your answer, and always wrap it in a timeout. Everything downstream of a write - email, indexing, analytics - should be an event.
3. Which transporter should I choose?
Ans: Match it to the delivery guarantee you need. TCP for simple internal request-response with no broker and no redelivery. NATS for low-latency RPC plus lightweight pub/sub with queue groups. RabbitMQ when work must not be lost and you want per-message acks and dead-letter queues. Kafka when you need a durable, replayable, partitioned log or multiple independent consumer groups over the same stream. gRPC for typed, high-throughput synchronous calls. Do not pick Kafka because it is the one you have heard of.
4. Can two services share a database if one of them is read-only?
Ans: No, and "read-only" is how it always starts. A reader depends on your schema just as hard as a writer, so you can no longer rename a column, change a type, or drop a table without a cross-team migration - which is exactly the coupling you split the system to remove. Expose an endpoint, or publish events and let the other service maintain its own projection shaped for its own queries.
5. How do I do a transaction across services?
Ans: You do not - you write a saga. Break the operation into local transactions, one per service, and define an explicit compensating action for each so a later failure can undo the earlier steps. Start with an orchestrated saga where one coordinator owns the sequence; it is far easier to debug than choreography, where the flow exists only as an implicit chain of events. Persist the saga state so a coordinator crash does not strand the work.
6. How do I stop the same message being processed twice?
Ans: Assume it will be, because at-least-once is what brokers guarantee. Have the producer stamp a stable message ID, and have the consumer claim that ID in a deduplication store with a TTL before doing any work - if the claim fails, the message was already handled, so acknowledge and return. Where possible make the operation naturally idempotent (an upsert rather than an insert, SET rather than INCR), which is better than deduplication because it needs no extra state.
7. Monorepo or one repository per service?
Ans: Start with a monorepo using the Nest CLI's monorepo mode. It gives you atomic changes across a producer and its consumers, one dependency tree, and one CI pipeline - all of which matter most in the period when your boundaries are still moving. Split into separate repositories when teams need genuinely independent release cadences or different security boundaries. The repository layout is a logistics decision; the module boundaries are the architectural one.
8. How do I test a service without running the whole system?
Ans: In three layers. Unit-test providers with fakes injected through the constructor - this is what DI was for, and it should cover the bulk of your logic. Integration-test the module with Test.createTestingModule() and a real database in Testcontainers, overriding only the remote clients. Then use contract tests so a producer cannot ship an event shape its consumers do not accept. End-to-end tests across every service are slow and flaky; keep a handful for the critical path only.
9. Isn't NestJS slow? All those decorators and that DI container.
Ans: The container resolves the dependency graph once at bootstrap, not per request, so the steady-state overhead is small and is nearly always dwarfed by your database queries and network hops. If the HTTP layer is genuinely your bottleneck - measure first - switch to the Fastify adapter. In practice the performance problems in Nest microservices are architectural: an N+1 fan-out of remote calls, a missing timeout that pins the event loop, or a synchronous chain where events would do.
9. Conclusion
NestJS rewards developers who understand what it actually is. Not a faster Express, and not a licence to start with twelve services, but a framework that makes the structure of your application explicit enough that you can see your boundaries before you have to defend them across a network. Every meaningful decision in a NestJS microservice system follows from a handful of properties, and every classic distributed-systems bug follows from ignoring one of them:
- The module graph is the design. Small export lists, injection through abstractions, and no module reaching into another's data - get that right in one process and extraction becomes a change of transport.
- Split on ownership, not on nouns. If two services need a distributed transaction for their most common operation, they are one service cut in the wrong place.
- The transport is a contract choice. send() couples availability and latency; emit() does not. Default to events and pay for synchronous calls consciously.
- Every consumer runs twice. At-least-once is the guarantee you get, so idempotency is a correctness requirement, and the outbox is what stops the event and the state change from disagreeing.
- Resilience is configuration you write. Timeouts on every call, bounded retries with backoff and jitter, circuit breakers with fallbacks, and a shutdown that drains rather than drops.
- You cannot debug what you did not instrument. Correlation IDs, distributed traces, structured logs, and consumer lag - added before the split, not after the first incident.
Used carelessly, microservices become a distributed monolith: all the network failure modes of a distributed system with none of the independence that was supposed to pay for them. Used deliberately, they disappear - teams ship without asking permission, one hot path scales on its own, and a failure in recommendations leaves checkout untouched. That invisibility is the mark of a system built by someone who understood the trade-offs rather than someone who simply added services.
About the Author:Abodh is a PHP and Laravel Developer at AddWeb Solution, skilled in MySQL, REST APIs, JavaScript, Git, and Docker for building robust web applications.
Top comments (0)