Integrate internal services
How services in the SAME platform talk to each other — sync RPC, async fan-out, robust consumers,
cross-service reads, context propagation, and the worker shape. Examples NestJS/TS, neutral
listing/order/payment domain; <Svc> is a placeholder. principle → ▸ Example → ▸ Other
stacks. The client side of a call (proxy lifecycle/retries/base send()) and the single
producer→consumer event live in write-service-code §6/§9 — this skill is the rest of the mesh.
For third-party/vendor systems and inbound webhooks, see integrate-external-services.
When to use
You're exposing an operation for another service to call, fanning one event out to many subscribers,
hardening a queue consumer, resolving data that lives in another service, or building a pure worker.
1. Synchronous RPC — server side + a uniform envelope
- Expose operations via a message-pattern handler (the reply side); the client side (proxy
lifecycle, retries, base
send()) is write-service-code §9. Keep the handler thin — delegate to
a command/query bus.
- Wrap every request and reply in a stable envelope, never bare payloads. Request carries
{ id (correlation), service (caller), pattern, input }; reply carries
{ success, data, message, statusCode }. One shared interceptor builds the success reply + logs
id/pattern; one shared exception filter maps a thrown error to a failed envelope — so every
caller gets the same shape and a trace id, always.@Controller()
@UseInterceptors(MicroserviceInterceptor) // wraps return value → { success:true, data }
@UseFilters(RpcExceptionFilter) // maps throw → { success:false, message, statusCode }
export class ListingRpcController {
@MessagePattern(LISTING_PATTERNS.getByIds)
getByIds(req: RpcRequest<GetByIdsInput>): Promise<ListingDto[]> {
return this.queryBus.execute(new GetListingsByIdsQuery(req.input));
}
}
- Version the contract (pattern names are constants in a shared registry); changing a reply shape
is a breaking change for callers — add a field, don't repurpose one.
▸ Other stacks: gRPC (status codes + metadata for correlation), a JSON-RPC envelope, Thrift. The
principle is universal: a versioned, uniform request/response contract with a correlation id and an
explicit error shape, not ad-hoc payloads.
2. Async fan-out — one event, many subscribers (topic → queues)
- For one-to-many, publish to a topic; each subscriber owns its own queue subscribed to
that topic, so subscribers fail/scale/retry independently. (One-to-one producer→consumer + the
outbound mapped-subset payload is
write-service-code §6.)
- A central registry maps topic → its subscriber queue names — no scattered string literals; the
producer broadcasts to the topic and never names a subscriber.
export const TOPICS = { ORDER_CREATED: 'order-created' } as const;
export const SUBSCRIBERS = {
[TOPICS.ORDER_CREATED]: { // one topic, N independent queues
grantLoyaltyPoints: 'grant-loyalty-points',
sendOrderReceipt: 'send-order-receipt',
},
};
await this.events.broadcast({ event: TOPICS.ORDER_CREATED, payload: { orderId } }); // no subscriber knowledge
▸ Other stacks: Kafka topic + consumer groups, Google Pub/Sub topic→subscriptions, RabbitMQ
exchange→queues. Principle: producer → topic, fan-out to independent subscriber queues, names in a
registry, not inline.
3. Consumer robustness — ack vs DLQ + lifecycle hooks
- Segregate failures — the single most important consumer rule (refines §6's "don't throw"):
- Permanent failure (validation, not-found, malformed payload) → log + ack/return so it does
NOT loop forever.
- Transient failure (downstream down, timeout, deadlock) → rethrow so the broker retries and
eventually routes to a DLQ.
- Never blanket-swallow (you silently lose retriable work) and never blanket-throw (permanent
failures become poison messages that loop until they expire).
- Centralize in a base handler (template method): the subclass implements
execute(payload); the
base parses, runs, and applies the ack-vs-rethrow rule once. Subscribe to lifecycle events
(received / processed / error / timeout) for metrics + replay visibility without touching business code.abstract class BaseConsumer {
abstract execute(payload: unknown): Promise<void>;
async handleMessage(msg: Message) {
try { await this.execute(parse(msg.Body)); }
catch (e) {
if (e instanceof ValidationError || e instanceof NotFoundError) { this.log.warn('drop', e); return; } // ack
throw e; // → retry/DLQ
}
}
@ConsumerEvent('processing_error') onError(e: Error, m: Message) { this.log.error('consumer error', { e, m }); }
}
▸ Other stacks: same — classify exceptions into terminal vs retriable; ack the terminal ones,
nack/redeliver→DLQ the retriable ones; emit metrics on consumer lifecycle.
4. Cross-service reads — batch + cache, never N+1 across the network
- Resolving ids → data from another service in a loop is an N+1 over the network (latency × N, and
it amplifies that service's load). Expose and call a bulk lookup — send all ids, get all rows in
one round trip.
- Cache another service's response locally (cache-through with a TTL) and invalidate on the
source's change event (subscribe to it). On the hot path you read your own cache/replica, not a
synchronous hop.
// bulk, cached, invalidated by the owner's event
getOrgs(ids: string[]) {
return this.cache.wrap(`${PREFIX.ORG}:${stableKey(ids)}`,
() => this.orgClient.send(ORG_PATTERNS.getByIds, { ids }), // ONE call for all ids
TTL);
}
@EventsHandler(OrgUpdatedEvent) // owner changed → drop our cache
handle(e) { return this.cache.del(`${PREFIX.ORG}:*`); }
▸ Other stacks: a batch endpoint (GraphQL dataloader, gRPC batch), or a local read-model/replica
fed by events (CQRS read side). Principle: batch the call, cache the result, invalidate on the
source's event — don't synchronously fan out per-row.
5. Propagate identity & context across hops
- Pass the caller's identity + tenant + a correlation/trace id downstream (in the envelope
id
field or a header) so every hop logs the same trace and can enforce tenant scope. A downstream
service trusts the gateway/upstream's asserted identity — a guard reads the injected
x-caller/x-tenant header it was given — instead of re-authenticating end-user credentials it
never received.@Injectable() export class CallerGuard implements CanActivate {
canActivate(ctx: ExecutionContext) {
const req = ctx.switchToHttp().getRequest();
if (!req.headers['x-caller']) throw new UnauthorizedException(); // upstream must assert it
req.caller = JSON.parse(req.headers['x-caller']); // { id, tenantId, roles }
return true;
}
}
- Pass the minimal claims the downstream needs (id, tenant/org, roles), not the whole user object.
Tenant query-scoping itself (intersecting the allowed set into the query) is
write-service-code §9.
▸ Other stacks: W3C traceparent / OpenTelemetry context propagation; a short-lived signed internal
JWT asserting the caller; gRPC metadata. Principle: forward identity + trace, trust the asserted
context at the edge, scope by tenant downstream.
6. Worker / consumer service shape
Verification
- Uniform RPC envelope: every reply is
{ success, data } or { success:false, message, statusCode }, never a bare payload; @MessagePattern handlers delegate to a bus and pattern names are imported constants (no inline string patterns). Call a handler that throws → the caller still gets a success:false envelope with a statusCode + correlation id.
- Fan-out is topic→queues:
grep -rn "broadcast\|TOPICS\." src — the producer publishes to a topic and names no subscriber; the topic→queue registry lists each subscriber's own queue. Take one subscriber offline → the others still receive the event (independent queues).
- Consumers classify failures: feed a malformed payload → it's logged + ack'd (queue depth doesn't grow); force a transient error (downstream down) → it rethrows and lands in the DLQ after retries.
grep -rn "DLQ\|ValidationError\|NotFoundError" src shows the terminal-vs-retriable split in one base handler.
- Cross-service reads batched + cached: id→data lookups send all ids in one call — no
.send( / RPC inside a .map( or loop; the result goes through cache.wrap and an @EventsHandler on the owner's change event invalidates it.
- Context propagated, worker drains: a downstream guard rejects a call missing
x-caller/x-tenant (401) and the same correlation id appears in logs across hops; the worker app has no business routes (grep -rn "@Controller" src ≈ health only) and on SIGTERM stops intake + finishes in-flight work before exit.
Related
write-service-code — §6 (single producer→consumer event + outbound mapped payload), §9 (client
proxy lifecycle/retries, tenant query-scoping, transactions + compensation), §7 (logging).
background-jobs-and-caching — Bull queues, Redis cache + idempotency, the cache-through wrap used in §4.
integrate-external-services — third-party vendor APIs, inbound webhooks, the partner/public API edge.
structure-a-backend-service — where these files live (the worker is a structural variant).
1---2name: integrate-internal-services3description: Use when one backend service calls or consumes from another inside the same platform — synchronous RPC (a uniform request/response envelope + server-side message handlers), SNS→SQS event fan-out (one topic → many subscriber queues), async-consumer robustness (ack vs DLQ + lifecycle hooks), cross-service reads (batch + cache, no network N+1), identity/context propagation across hops, and the worker/consumer service shape (no HTTP, graceful drain). NestJS/TS reference, framework-flexible. Complements write-service-code §6 (single producer→consumer events) and §9 (client-proxy lifecycle).4---56# Integrate internal services78How services in the SAME platform talk to each other — sync RPC, async fan-out, robust consumers,9cross-service reads, context propagation, and the worker shape. Examples NestJS/TS, neutral10`listing`/`order`/`payment` domain; `<Svc>` is a placeholder. principle → **▸ Example** → **▸ Other11stacks**. The *client* side of a call (proxy lifecycle/retries/base `send()`) and the single12producer→consumer event live in `write-service-code` §6/§9 — this skill is the rest of the mesh.13For third-party/vendor systems and inbound webhooks, see `integrate-external-services`.1415## When to use16You're exposing an operation for another service to call, fanning one event out to many subscribers,17hardening a queue consumer, resolving data that lives in another service, or building a pure worker.1819## 1. Synchronous RPC — server side + a uniform envelope20- **Expose operations via a message-pattern handler** (the reply side); the *client* side (proxy21 lifecycle, retries, base `send()`) is `write-service-code` §9. Keep the handler thin — delegate to22 a command/query bus.23- **Wrap every request and reply in a stable envelope, never bare payloads.** Request carries24 `{ id (correlation), service (caller), pattern, input }`; reply carries25 `{ success, data, message, statusCode }`. One shared **interceptor** builds the success reply + logs26 `id`/`pattern`; one shared **exception filter** maps a thrown error to a failed envelope — so every27 caller gets the same shape and a trace id, always.28 ```ts29 @Controller()30 @UseInterceptors(MicroserviceInterceptor) // wraps return value → { success:true, data }31 @UseFilters(RpcExceptionFilter) // maps throw → { success:false, message, statusCode }32 export class ListingRpcController {33 @MessagePattern(LISTING_PATTERNS.getByIds)34 getByIds(req: RpcRequest<GetByIdsInput>): Promise<ListingDto[]> {35 return this.queryBus.execute(new GetListingsByIdsQuery(req.input));36 }37 }38 ```39- **Version the contract** (pattern names are constants in a shared registry); changing a reply shape40 is a breaking change for callers — add a field, don't repurpose one.41▸ *Other stacks:* gRPC (status codes + metadata for correlation), a JSON-RPC envelope, Thrift. The42principle is universal: a versioned, uniform request/response contract with a correlation id and an43explicit error shape, not ad-hoc payloads.4445## 2. Async fan-out — one event, many subscribers (topic → queues)46- For **one-to-many**, publish to a **topic**; each subscriber owns its **own queue** subscribed to47 that topic, so subscribers fail/scale/retry independently. (One-to-one producer→consumer + the48 outbound mapped-subset payload is `write-service-code` §6.)49- **A central registry maps topic → its subscriber queue names** — no scattered string literals; the50 producer broadcasts to the topic and never names a subscriber.51 ```ts52 export const TOPICS = { ORDER_CREATED: 'order-created' } as const;53 export const SUBSCRIBERS = {54 [TOPICS.ORDER_CREATED]: { // one topic, N independent queues55 grantLoyaltyPoints: 'grant-loyalty-points',56 sendOrderReceipt: 'send-order-receipt',57 },58 };59 await this.events.broadcast({ event: TOPICS.ORDER_CREATED, payload: { orderId } }); // no subscriber knowledge60 ```61▸ *Other stacks:* Kafka topic + consumer groups, Google Pub/Sub topic→subscriptions, RabbitMQ62exchange→queues. Principle: producer → topic, fan-out to independent subscriber queues, names in a63registry, not inline.6465## 3. Consumer robustness — ack vs DLQ + lifecycle hooks66- **Segregate failures** — the single most important consumer rule (refines §6's "don't throw"):67 - **Permanent** failure (validation, not-found, malformed payload) → **log + ack/return** so it does68 NOT loop forever.69 - **Transient** failure (downstream down, timeout, deadlock) → **rethrow** so the broker retries and70 eventually routes to a **DLQ**.71 - Never blanket-swallow (you silently lose retriable work) and never blanket-throw (permanent72 failures become poison messages that loop until they expire).73- **Centralize in a base handler (template method):** the subclass implements `execute(payload)`; the74 base parses, runs, and applies the ack-vs-rethrow rule once. Subscribe to **lifecycle events**75 (received / processed / error / timeout) for metrics + replay visibility without touching business code.76 ```ts77 abstract class BaseConsumer {78 abstract execute(payload: unknown): Promise<void>;79 async handleMessage(msg: Message) {80 try { await this.execute(parse(msg.Body)); }81 catch (e) {82 if (e instanceof ValidationError || e instanceof NotFoundError) { this.log.warn('drop', e); return; } // ack83 throw e; // → retry/DLQ84 }85 }86 @ConsumerEvent('processing_error') onError(e: Error, m: Message) { this.log.error('consumer error', { e, m }); }87 }88 ```89▸ *Other stacks:* same — classify exceptions into terminal vs retriable; ack the terminal ones,90nack/redeliver→DLQ the retriable ones; emit metrics on consumer lifecycle.9192## 4. Cross-service reads — batch + cache, never N+1 across the network93- **Resolving ids → data from another service in a loop is an N+1 over the network** (latency × N, and94 it amplifies that service's load). Expose and call a **bulk lookup** — send all ids, get all rows in95 one round trip.96- **Cache another service's response locally** (cache-through with a TTL) and **invalidate on the97 source's change event** (subscribe to it). On the hot path you read your own cache/replica, not a98 synchronous hop.99 ```ts100 // bulk, cached, invalidated by the owner's event101 getOrgs(ids: string[]) {102 return this.cache.wrap(`${PREFIX.ORG}:${stableKey(ids)}`,103 () => this.orgClient.send(ORG_PATTERNS.getByIds, { ids }), // ONE call for all ids104 TTL);105 }106 @EventsHandler(OrgUpdatedEvent) // owner changed → drop our cache107 handle(e) { return this.cache.del(`${PREFIX.ORG}:*`); }108 ```109▸ *Other stacks:* a batch endpoint (GraphQL dataloader, gRPC batch), or a local read-model/replica110fed by events (CQRS read side). Principle: batch the call, cache the result, invalidate on the111source's event — don't synchronously fan out per-row.112113## 5. Propagate identity & context across hops114- **Pass the caller's identity + tenant + a correlation/trace id downstream** (in the envelope `id`115 field or a header) so every hop logs the same trace and can enforce tenant scope. A downstream116 service **trusts the gateway/upstream's asserted identity** — a guard reads the injected117 `x-caller`/`x-tenant` header it was given — instead of re-authenticating end-user credentials it118 never received.119 ```ts120 @Injectable() export class CallerGuard implements CanActivate {121 canActivate(ctx: ExecutionContext) {122 const req = ctx.switchToHttp().getRequest();123 if (!req.headers['x-caller']) throw new UnauthorizedException(); // upstream must assert it124 req.caller = JSON.parse(req.headers['x-caller']); // { id, tenantId, roles }125 return true;126 }127 }128 ```129- **Pass the minimal claims** the downstream needs (id, tenant/org, roles), not the whole user object.130 Tenant *query*-scoping itself (intersecting the allowed set into the query) is `write-service-code` §9.131▸ *Other stacks:* W3C `traceparent` / OpenTelemetry context propagation; a short-lived signed internal132JWT asserting the caller; gRPC metadata. Principle: forward identity + trace, trust the asserted133context at the edge, scope by tenant downstream.134135## 6. Worker / consumer service shape136- **A pure consumer (queue/cron worker) boots WITHOUT request routes.** Create the app, wire the137 microservice/queue consumers, expose **only a minimal health/liveness port** — no controllers, no138 Swagger. (For where files live, this is a structural variant of `structure-a-backend-service`.)139 ```ts140 const app = await NestFactory.create(WorkerModule);141 app.connectMicroservice(config.get(tcpOptions)); // queue/RPC consumers142 await app.startAllMicroservices();143 app.get(ShutdownObserver).setupGracefulShutdown(app);144 await app.listen(PORT); // health probe only — no business routes145 ```146- **Drain on shutdown:** flip a shutting-down flag, stop accepting new messages, let in-flight handlers147 finish (`queue.close()`, `clientProxy.close()`), then exit — a deploy must not drop work. The RPC148 interceptor **rejects new requests** (`SERVICE_UNAVAILABLE`) while draining.149▸ *Other stacks:* a Sidekiq/Celery/River worker, a Kafka consumer service, a Cloud Run/Lambda150consumer. Principle: no request server, graceful drain of in-flight work, health probe only.151152## Verification153- **Uniform RPC envelope:** every reply is `{ success, data }` or `{ success:false, message, statusCode }`, never a bare payload; `@MessagePattern` handlers delegate to a bus and pattern names are imported constants (no inline string patterns). Call a handler that throws → the caller still gets a `success:false` envelope with a `statusCode` + correlation id.154- **Fan-out is topic→queues:** `grep -rn "broadcast\|TOPICS\." src` — the producer publishes to a topic and names **no** subscriber; the topic→queue registry lists each subscriber's own queue. Take one subscriber offline → the others still receive the event (independent queues).155- **Consumers classify failures:** feed a malformed payload → it's logged + **ack'd** (queue depth doesn't grow); force a transient error (downstream down) → it **rethrows** and lands in the DLQ after retries. `grep -rn "DLQ\|ValidationError\|NotFoundError" src` shows the terminal-vs-retriable split in one base handler.156- **Cross-service reads batched + cached:** id→data lookups send **all** ids in one call — no `.send(` / RPC inside a `.map(` or loop; the result goes through `cache.wrap` and an `@EventsHandler` on the owner's change event invalidates it.157- **Context propagated, worker drains:** a downstream guard rejects a call missing `x-caller`/`x-tenant` (`401`) and the same correlation id appears in logs across hops; the worker app has no business routes (`grep -rn "@Controller" src` ≈ health only) and on SIGTERM stops intake + finishes in-flight work before exit.158159## Related160- `write-service-code` — §6 (single producer→consumer event + outbound mapped payload), §9 (client161 proxy lifecycle/retries, tenant query-scoping, transactions + compensation), §7 (logging).162- `background-jobs-and-caching` — Bull queues, Redis cache + idempotency, the cache-through `wrap` used in §4.163- `integrate-external-services` — third-party vendor APIs, inbound webhooks, the partner/public API edge.164- `structure-a-backend-service` — where these files live (the worker is a structural variant).