Contracts Between Services

A service's contract is its pattern, its payload, its reply and its error shape, and no compiler checks any of them across a process.

Syntax@MessagePattern({ cmd: 'billing.quote', v: 2 })

Two services that talk share a contract: the pattern that routes the call, the payload shape, the reply shape and the error shape. Inside one process TypeScript checks all four. Across a transport it checks none of them. Both sides compile; only the running system disagrees.

The pattern is part of the contract

Nest turns an object pattern into a route string with its keys sorted, so { cmd: 'a', v: 1 } and { v: 1, cmd: 'a' } are the same route - key order is safe. Adding or renaming a key is not. It produces a different route, no handler matches it, and the caller waits for a reply that will never come until its own timeout fires.

What breaks

ChangeBreaking?
Add an optional payload fieldNo
Add a required payload fieldYes
Rename or remove a payload fieldYes
Narrow a field's accepted valuesYes
Add or rename a key in the patternYes - new route
Register an extra patternNo
Remove a reply fieldYes

Versioning

Put the version in the pattern and keep both handlers until the last caller has moved. Deleting the old handler is the breaking change, not adding the new one, and a handler that delegates to the same service method costs a few lines.

Errors are contract too

Only RpcException survives the trip: construct it with an object and that object is sent verbatim; with a string and the caller gets { status: 'error', message }. Everything else is logged on the service and replaced by { status: 'error', message: 'Internal server error' }.

Example

Example · typescript
// libs/contracts/src/billing.ts
// Imported by the caller and by the service. One file, one source of truth.
// `as const` freezes the literal types so a typo at a call site is a
// compile error on both sides rather than a route that matches nothing.
export const BILLING_QUOTE_V1 = { cmd: 'billing.quote', v: 1 } as const;
export const BILLING_QUOTE_V2 = { cmd: 'billing.quote', v: 2 } as const;

export interface QuoteRequestV1 {
  orderId: string;
}

export interface QuoteRequestV2 {
  orderId: string;
  currency: string; // new required field, so it needed a new version
}

export interface QuoteReply {
  cents: number;
  currency: string;
}

// src/billing/billing.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import {
  BILLING_QUOTE_V1,
  BILLING_QUOTE_V2,
  QuoteReply,
  QuoteRequestV1,
  QuoteRequestV2,
} from '@app/contracts/billing.js';
import { BillingService } from './billing.service.js';

@Controller()
export class BillingController {
  constructor(private readonly billing: BillingService) {}

  // v1 stays registered until the last caller has migrated. Both handlers
  // delegate to the same service method; only the defaulting differs.
  @MessagePattern(BILLING_QUOTE_V1)
  quoteV1(@Payload() body: QuoteRequestV1): Promise<QuoteReply> {
    return this.billing.quote(body.orderId, 'EUR'); // v1's implied currency
  }

  @MessagePattern(BILLING_QUOTE_V2)
  quoteV2(@Payload() body: QuoteRequestV2): Promise<QuoteReply> {
    return this.billing.quote(body.orderId, body.currency);
  }
}

When to use it

  • Add a required field to a message without stranding the callers you do not control.
  • Work out why a caller times out after a pattern was edited on the service.
  • Give a caller a readable validation error instead of 'Internal server error'.
  • Share pattern constants and payload types between a gateway and a service in one repo.

More examples

Validating at the boundary, with errors the caller can read

Pipes run after guards and immediately before the handler, so this is the last place a bad payload can be stopped cheaply. The two details that matter are the exceptionFactory, which keeps the reason visible to the caller, and leaving forbidNonWhitelisted off, which keeps additive changes additive.

Example · typescript
// libs/contracts/src/quote-request.dto.ts
import { IsISO4217CurrencyCode, IsOptional, IsUUID } from 'class-validator';

export class QuoteRequestDto {
  @IsUUID()
  orderId!: string;

  @IsOptional()
  @IsISO4217CurrencyCode()
  currency?: string;
}

// src/main.ts (excerpt)
import { ValidationPipe } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    // Strip properties with no decorator. Do NOT set forbidNonWhitelisted
    // here: a newer caller that adds an optional field would be rejected
    // outright, which turns a safe change into a breaking one.
    whitelist: true,
    // Without this the pipe throws a BadRequestException, and the caller
    // receives only { status: 'error', message: 'Internal server error' }.
    exceptionFactory: errors =>
      new RpcException({
        status: 'error',
        code: 'VALIDATION_FAILED',
        message: errors.flatMap(e => Object.values(e.constraints ?? {})),
      }),
  }),
);

// src/billing/billing.controller.ts (excerpt)
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { QuoteRequestDto } from '@app/contracts/quote-request.dto.js';

@Controller()
export class BillingController {
  @MessagePattern({ cmd: 'billing.quote', v: 2 })
  quote(@Payload() body: QuoteRequestDto) {
    // body is a QuoteRequestDto instance, validated before this line ran.
    return { orderId: body.orderId, currency: body.currency ?? 'EUR' };
  }
}

Schema validation on @Payload (NestJS 12)

Reach for this when the contract is already described by a zod or valibot schema and you do not want a parallel set of decorated DTO classes. The schema travels in the parameter metadata, so one global pipe validates every handler that declares one, and the exceptionFactory is again what keeps the failure legible to the caller.

Example · typescript
// src/main.ts (excerpt)
import { StandardSchemaValidationPipe } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';

// NestJS 12 added StandardSchemaValidationPipe. It validates a parameter
// against a schema attached to that parameter's metadata, using any library
// that implements the Standard Schema spec (zod, valibot, arktype).
app.useGlobalPipes(
  new StandardSchemaValidationPipe({
    transform: true, // return the schema's parsed value, including coercions
    exceptionFactory: issues =>
      new RpcException({
        status: 'error',
        code: 'VALIDATION_FAILED',
        message: issues.map(issue => issue.message),
      }),
  }),
);

// src/billing/billing.controller.ts
import { Controller, ParseIntPipe } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { z } from 'zod';

const QuoteRequest = z.object({
  orderId: z.string().min(1),
  currency: z.string().length(3).default('EUR'),
});

@Controller()
export class BillingController {
  @MessagePattern({ cmd: 'billing.quote', v: 2 })
  quote(@Payload({ schema: QuoteRequest }) body: z.infer<typeof QuoteRequest>) {
    return { orderId: body.orderId, currency: body.currency };
  }
}

// src/billing/legacy-billing.controller.ts
@Controller()
export class LegacyBillingController {
  // Shorthand and longhand for the same parameter binding: pipes can be
  // passed as rest arguments, or inside the options object alongside a
  // schema. These two lines are equivalent.
  @MessagePattern({ cmd: 'billing.retries' })
  retries(@Payload('attempts', ParseIntPipe) attempts: number) {
    // retries(@Payload('attempts', { pipes: [ParseIntPipe] }) attempts: number)
    return { attempts };
  }
}

Events: writing a consumer that tolerates change

With events the producer cannot know who is listening, so the contract has to survive being read by an older consumer. Keep every post-release field optional, never repurpose a field name, and ignore what you do not understand. This is also what makes a replay of an old topic safe.

Example · typescript
// libs/contracts/src/events.ts
export const PAYMENT_CAPTURED = 'payment.captured';

// Version 2 of this event added `method`. Old consumers must still work, so
// every field added after the first release is optional in the type.
export interface PaymentCapturedV1 {
  orderId: string;
  cents: number;
  currency: string;
  method?: 'card' | 'sepa'; // added later; absent on replayed older events
}

// src/orders/payments.consumer.ts
import { Controller, Logger } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { PAYMENT_CAPTURED, PaymentCapturedV1 } from '@app/contracts/events.js';
import { OrdersService } from './orders.service.js';

@Controller()
export class PaymentsConsumer {
  private readonly logger = new Logger(PaymentsConsumer.name);

  constructor(private readonly orders: OrdersService) {}

  @EventPattern(PAYMENT_CAPTURED)
  async onCaptured(@Payload() event: PaymentCapturedV1) {
    // Read only the fields this consumer needs, and treat anything added
    // later as possibly missing. A consumer that destructures every field
    // breaks the moment the producer is deployed ahead of it.
    if (!event.orderId) {
      this.logger.warn('payment.captured without orderId, ignoring');
      return;
    }
    await this.orders.markPaid(event.orderId, event.cents);
  }
}

Discussion

  • Be the first to comment on this lesson.