Skip to main content

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:

  1. Schema drift across boundaries. Two services that both think they "know" the room model can drift apart whenever one changes its schema. Migrations on the owning service silently break consumers.
  2. 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:

  1. Builds an axios instance pointed at the target service's internal URL.
  2. Reads INTERNAL_SERVICE_TOKEN from env on construction and attaches it as Authorization: Bearer <jwt> on every request.
  3. Pulls tenantId, propertyId, and requestId from AsyncLocalStorage (set by the CorrelationIdMiddleware and the auth guard) and forwards them as x-tenant-id, x-property-id, x-request-id headers.
  4. Wraps every call in a BusinessCacheService envelope (5-minute TTL) for read-heavy endpoints — see cache.ts in @hmp/cache.
  5. 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)​

ServiceFileMethod(s)Branch
pos-serviceorders.service.ts2× room lookup → PropertyServiceClient.getRoomfeat/xprisma-to-serviceclient
housekeeping-servicetasks.service.ts2× room status → PropertyServiceClient.updateRoomStatusfeat/xprisma-to-serviceclient
reservation-serviceavailability.service.ts5 sites → 5 new property-service endpointsfeat/booking-engine-serviceclient
reservation-servicebooking-engine.service.ts8 sites → 6 new property-service endpointsfeat/booking-engine-serviceclient
reservation-servicecart.service.ts4 sites → getRoomTypesWithRatePlans, getAvailableRoomfeat/booking-engine-serviceclient
reservation-servicerate-plans.service.ts2 sites → getRatePlansWithRatesfeat/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.