When Not to Split

Splitting into services buys independent deploys and charges partial failure, consistency work and a slower loop up front.

Splitting an application into services buys independent deploys, independent scaling and a hard fault boundary. It charges for them first, and the bill arrives long before the benefit does.

What a process boundary costs

A method call cannot half-happen. A message can: sent and not delivered, delivered and not handled, handled and the reply lost. Every call site that was one line becomes three cases. You also lose the compiler across the boundary - @Payload() is typed as whatever you claim - and you lose the database transaction, because two services cannot share one. "Charge the card and mark the order paid" now needs an outbox or a saga.

Then the running costs: several processes and a broker to start before you can run one test, deploy ordering between services whose contracts changed together, authentication repeated at every entry point, and a correlation id threaded through everything before any bug is debuggable at all.

Nest gives you the cheap boundary first

A @Module is already a boundary and the injector enforces it: a provider is injectable elsewhere only if its module exports it. That boundary is free, reversible, and checked when the app starts. Get it right in one process. The module you could extract in an afternoon is the only one worth extracting at all.

When the split is right

A genuinely different scaling shape, a different runtime need such as a GPU or multi-hour jobs, hard isolation for card data, or a component that must keep serving while the rest restarts. "A different team" counts only when the code boundary already matches the team boundary.

Example

Example · typescript
// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from './order.entity.js';
import { OrdersController } from './orders.controller.js';
import { OrdersRepository } from './orders.repository.js';
import { OrdersService } from './orders.service.js';

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  controllers: [OrdersController],
  providers: [OrdersService, OrdersRepository],
  // The whole discipline is this line. OrdersRepository and the Order entity
  // are NOT exported, so no other module can reach into this one's tables;
  // the injector refuses at startup if one tries. Everything outside goes
  // through OrdersService.
  //
  // That is what makes extraction later a mechanical job: the only thing to
  // replace is OrdersService, and the call sites do not change.
  exports: [OrdersService],
})
export class OrdersModule {}

When to use it

  • Decide whether reporting needs its own service or just its own module.
  • Answer an internal caller on a transport while still shipping one deployable.
  • Decouple two features today without running a broker in every developer's setup.
  • Keep a later extraction to a provider swap instead of a rewrite.

More examples

One deployable, two entry points

A hybrid app answers the common reason for splitting - an internal caller that should not use the public URL - without paying for a second deployable. Use it when the motivation is access or protocol rather than independent release and scaling.

Example · typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // The same controllers, providers and guards now answer on a TCP socket as
  // well as on HTTP. One repository, one deploy, one set of logs, one
  // database transaction still available to everything inside.
  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.TCP,
    options: { host: '0.0.0.0', port: 3001 },
  });

  await app.startAllMicroservices();
  await app.listen(3000);
}

bootstrap();

// src/orders/orders.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { OrdersService } from './orders.service.js';

@Controller('orders')
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Get(':id')
  findHttp(@Param('id') id: string) {
    return this.orders.findOne(id);
  }

  // Same service call, reachable by an internal caller without going through
  // the public URL, TLS termination or the public auth guard.
  @MessagePattern({ cmd: 'order.find' })
  findRpc(@Payload('id') id: string) {
    return this.orders.findOne(id);
  }
}

In-process events first

Publish/subscribe is a design choice; a broker is a deployment choice. @nestjs/event-emitter 12 gets you the decoupling in one process, and because the handler signature matches @EventPattern, the later move across a transport touches two lines. Note it is in-process only: a crash loses the event, which is exactly the tradeoff you are postponing.

Example · typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { OrdersModule } from './orders/orders.module.js';
import { ShippingModule } from './shipping/shipping.module.js';

@Module({
  imports: [EventEmitterModule.forRoot(), OrdersModule, ShippingModule],
})
export class AppModule {}

// src/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';

@Injectable()
export class OrdersService {
  constructor(private readonly events: EventEmitter2) {}

  async markPaid(orderId: string, cents: number) {
    // ... persist first, then announce.
    // Synchronous and in-process: one stack trace, one transaction boundary,
    // no broker to run locally.
    this.events.emit('payment.captured', { orderId, cents });
  }
}

// src/shipping/shipping.listener.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';

@Injectable()
export class ShippingListener {
  // @OnEvent here, @EventPattern in a microservice. Same shape, same payload,
  // same handler body - so moving shipping into its own service later is a
  // change of decorator and module, not a redesign.
  @OnEvent('payment.captured')
  handle(payload: { orderId: string; cents: number }) {
    return this.schedulePickup(payload.orderId);
  }

  private schedulePickup(orderId: string) {
    return Promise.resolve(orderId);
  }
}

Leaving the extraction seam open

This is the shape of a reversible decision. The port is a token, the module chooses the implementation, and the split becomes a provider swap you can roll back with an environment variable. Worth doing for the one or two dependencies you actually expect to extract, and not for every service in the app.

Example · typescript
// src/orders/billing.port.ts
// An abstract class works as an injection token and as the type. Callers
// depend on this, never on an implementation.
export abstract class BillingPort {
  abstract quote(orderId: string): Promise<{ cents: number }>;
}

// src/orders/local-billing.service.ts
import { Injectable } from '@nestjs/common';
import { BillingPort } from './billing.port.js';
import { PriceTable } from './price-table.js';

@Injectable()
export class LocalBilling extends BillingPort {
  constructor(private readonly prices: PriceTable) {
    super();
  }

  quote(orderId: string): Promise<{ cents: number }> {
    return this.prices.totalFor(orderId);
  }
}

// src/orders/remote-billing.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';
import { BillingPort } from './billing.port.js';

@Injectable()
export class RemoteBilling extends BillingPort {
  constructor(@Inject('BILLING') private readonly client: ClientProxy) {
    super();
  }

  quote(orderId: string): Promise<{ cents: number }> {
    return firstValueFrom(
      this.client
        .send<{ cents: number }>({ cmd: 'billing.quote', v: 1 }, { orderId })
        .pipe(timeout(2000)),
    );
  }
}

// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { BillingPort } from './billing.port.js';
import { LocalBilling } from './local-billing.service.js';
import { OrdersService } from './orders.service.js';
import { PriceTable } from './price-table.js';
import { RemoteBilling } from './remote-billing.service.js';

@Module({
  imports: [
    ClientsModule.register([
      { name: 'BILLING', transport: Transport.TCP, options: { port: 3001 } },
    ]),
  ],
  providers: [
    // Shorthand: the class is both the token and the implementation.
    OrdersService,
    PriceTable,
    // The same registration written out in full - these are one provider:
    // { provide: OrdersService, useClass: OrdersService },

    // The token stays; only what satisfies it changes. Nothing that injects
    // BillingPort is edited when billing moves out of the process.
    {
      provide: BillingPort,
      useClass: process.env.BILLING_REMOTE === 'true' ? RemoteBilling : LocalBilling,
    },
  ],
  exports: [OrdersService],
})
export class OrdersModule {}

Discussion

  • Be the first to comment on this lesson.