Cross-Service Communication
Status: Active migration in progress (Phase 1 hardening, 2026-04). 23 of 75 sites migrated to ServiceClient pattern. 52 sites deferred to Phase 2.
This document is the canonical guide for how KeyNova backend services read data from each
other. The short version: never reach into another service's database. Always go through
its HTTP API using the typed ServiceClient from @hmp/service-clients.
The xPrisma Anti-Pattern (and Why We're Killing It)
When the monorepo was split into 14 per-service repos, every service got a slim Prisma schema
covering only the models it owns. But code copied from the monorepo still tried to read from
other services' models — typically through a wrapper called xPrisma (extended Prisma).
That code crashes at runtime with Cannot read properties of undefined (reading 'findMany')
because the Prisma client for service X doesn't have model Y.
It also creates two structural problems:
- Schema drift across boundaries. Two services that both think they "know" the
roommodel can drift apart whenever one changes its schema. Migrations on the owning service silently break consumers. - Tenant isolation gaps. A direct DB read can skip the owning service's
assertPropertyTenant()checks, letting tenant-A queries return tenant-B rows.
Both bugs were hit in production seed runs in March 2026. The fix is to route every cross-service read through HTTP, where the receiver enforces its own tenant + property ownership.
See docs/architecture/CROSS-SERVICE-DATA-ACCESS-ISSUES.md for the original incident catalog
that drove this migration.
The ServiceClient Pattern
Each service that exposes data publishes a typed client in @hmp/service-clients. Callers
inject the client and call methods that look like local function calls but compile to HTTP.
BaseServiceClient
All clients extend BaseServiceClient, which:
- Builds an axios instance pointed at the target service's internal URL.
- Reads
INTERNAL_SERVICE_TOKENfrom env on construction and attaches it asAuthorization: Bearer <jwt>on every request. - Pulls
tenantId,propertyId, andrequestIdfromAsyncLocalStorage(set by theCorrelationIdMiddlewareand the auth guard) and forwards them asx-tenant-id,x-property-id,x-request-idheaders. - Wraps every call in a
BusinessCacheServiceenvelope (5-minute TTL) for read-heavy endpoints — seecache.tsin@hmp/cache. - Translates 4xx/5xx responses into typed exceptions (
PropertyNotFoundError,PropertyForbiddenError, etc.) so callers can catch them like any other error.
Concrete client: PropertyServiceClient
import { Injectable } from '@nestjs/common';
import { PropertyServiceClient } from '@hmp/service-clients';
@Injectable()
export class OrdersService {
constructor(private readonly propertyClient: PropertyServiceClient) {}
async chargeRoom(orderId: string, roomNumber: string): Promise<void> {
// Reads from property-service via HTTP, automatically scoped to current tenant + property
const room = await this.propertyClient.getRoom({ roomNumber });
if (!room) throw new NotFoundException(`Room ${roomNumber} not found`);
// ...post charge to folio
}
}
The previous code looked like:
// BAD — this crashes because pos-service Prisma doesn't have a `room` model
const room = await this.xPrisma.room.findFirst({
where: { tenantId, propertyId, roomNumber },
});
AsyncLocalStorage Context Propagation
Backends never pass tenantId or propertyId as method arguments. They live in
AsyncLocalStorage (the request context), set once by CorrelationIdMiddleware +
JwtAuthGuard, and read by BaseServiceClient for outbound requests.
// In a controller
@Get('charge')
async charge(@Req() req: Request) {
// tenantId and propertyId are already in ALS by the time we get here
return this.runWithRequestContext(() => this.ordersService.chargeRoom(...));
}
For background jobs or cron handlers that don't have an inbound HTTP request, use
runWithRequestContext({ tenantId, propertyId, requestId }) from @hmp/common to
seed the ALS store before invoking any service-client method.
Making a JWT-Gated Endpoint Callable from Another Service
The receiver doesn't need any new code. The same JwtAuthGuard that accepts user tokens
also accepts service-account tokens (see internal-service-auth.md). What it must do is
re-enforce tenant/property ownership server-side, because the calling service is sending
headers it could lie about.
The standard pattern:
@Controller('properties/:propertyId/booking-engine')
@UseGuards(JwtAuthGuard)
export class BookingEngineSupportController {
constructor(private readonly service: BookingEngineSupportService) {}
@Get('availability-snapshot')
async getSnapshot(
@Param('propertyId') propertyId: string,
@CurrentTenant() tenantId: string,
@Query('from') from: string,
@Query('to') to: string,
) {
// CRITICAL: receiver-side check, runs even for service-account tokens
await this.service.assertPropertyTenant(propertyId, tenantId);
return this.service.getAvailabilitySnapshot(propertyId, from, to);
}
}
assertPropertyTenant() does a single SELECT to verify the property is owned by the claimed
tenant. The result is cached for 5 minutes. This is a non-negotiable part of any
cross-service endpoint.
Migration Status
Migrated (23 sites — Phase 1, 2026-04)
| Service | File | Method(s) | Branch |
|---|---|---|---|
| pos-service | orders.service.ts | 2× room lookup → PropertyServiceClient.getRoom | feat/xprisma-to-serviceclient |
| housekeeping-service | tasks.service.ts | 2× room status → PropertyServiceClient.updateRoomStatus | feat/xprisma-to-serviceclient |
| reservation-service | availability.service.ts | 5 sites → 5 new property-service endpoints | feat/booking-engine-serviceclient |
| reservation-service | booking-engine.service.ts | 8 sites → 6 new property-service endpoints | feat/booking-engine-serviceclient |
| reservation-service | cart.service.ts | 4 sites → getRoomTypesWithRatePlans, getAvailableRoom | feat/booking-engine-serviceclient |
| reservation-service | rate-plans.service.ts | 2 sites → getRatePlansWithRates | feat/booking-engine-serviceclient |
Deferred to Phase 2 (52 sites)
The largest remaining clusters:
- frontdesk-service:
folios.service.ts,night-audit.service.ts(audit log + guest + reservation reads — 8 sites) - channel-service:
booking-download.cron.ts,sync.service.ts(reservation/room/ roomType/property reads — 12 sites) - crm-service:
drip-engine.service.ts,guest-dedup.service.ts, feedback handler (guest + communication reads — 9 sites) - notification-service:
notification-queue.service.ts(notification list — 4 sites) - analytics-service: report generators (mixed reads — 14 sites)
- banquet-service, spa-service: smaller batches — 5 sites total
Phase 2 will follow the same pattern: introduce new endpoints on the owning service, extend
the matching *ServiceClient in @hmp/service-clients, replace xPrisma calls one site at
a time, and verify with isolation tests.
Example: Booking Engine Migration (C4b)
Before — availability.service.ts
async getSnapshot(propertyId: string, from: Date, to: Date) {
const roomTypes = await this.xPrisma.roomType.findMany({
where: { propertyId, isActive: true },
include: { rooms: true },
});
// ...build snapshot from rooms
}
This crashed because reservation-service's Prisma client has no roomType model.
After
async getSnapshot(propertyId: string, from: Date, to: Date) {
return this.runWithRequestContext(async () => {
return this.propertyClient.getAvailabilitySnapshot({ propertyId, from, to });
});
}
property-service now exposes GET /properties/:id/booking-engine/availability-snapshot.
The new endpoint owns the snapshot logic, enforces assertPropertyTenant(), and is fully
testable in isolation. The reservation-service became a thin caller.
See docs/api/booking-engine-support-api.md for the complete endpoint catalog.
Related Docs
internal-service-auth.md— service-account JWT mechanismmulti-property-isolation.md— tenant + property scopingwebsocket-gateway-auth.md— WebSocket authenticationanalytics-event-flow.md— async events for materialized viewsCROSS-SERVICE-DATA-ACCESS-ISSUES.md— original incident catalog