Skip to main content

xPrisma Anti-Pattern Migration Plan

Date: 2026-05-06 Roadmap reference: W3.E.1 (5 engineer-days) Audit reference: property-1-launch-review-2026-05-06.md §4.2 — "79 foreign-model Prisma access sites in channel + reservation services alone."

This document is the deliverable substituting the unimplemented multi-week W3.E.1 work. It catalogs every xPrisma site, classifies each by risk, and prescribes the migration approach. The 10 highest-risk sites get migrated in W3 per roadmap; the rest follow a per-service rollout post-launch.


§1 — What is the xPrisma anti-pattern?​

A service casts PrismaService to any (the variable name is conventionally xPrisma) and reads tables that belong to a different service:

// In hmp-reservation-service:
const property = await this.xPrisma.property.findUnique({...}); // Property is owned by property-service
const guest = await this.xPrisma.guest.findFirst({...}); // Guest is owned by guest-service

This works in dev (single shared DB) but breaks when services use separate DBs. Even when DBs are shared:

  • The slim per-service Prisma schema may not have the foreign model defined → runtime crash.
  • The local row may be a stale read-model copy (synced via event bus); if the sync consumer lags, callers see stale data.
  • Schema changes on the owning service silently break consumer code.

Canonical replacement: @hmp/service-clients HTTP calls (with internal-service-token JWT, AsyncLocalStorage tenant/property, 5-min Redis cache).


§2 — Inventory: 79 sites across 7 services​

Counts from grep -rE "prisma\.(folio|payment|property|guest|roomType|ratePlan|reservation)\." hmp-*-service/src (excluding spec files):

ServiceForeign models accessedSite count
hmp-reservation-serviceproperty, guest, roomType, ratePlan41
hmp-channel-serviceroomType, ratePlan, reservation13
hmp-frontdesk-servicereservation, room, roomType, property, guest25 (via xPrisma cast in checkin/folios/checkout/night-audit)
hmp-housekeeping-serviceroom, roomType (read-model — lower risk)8
hmp-guest-serviceproperty, reservation (read-model)6
hmp-banquet-serviceguest (synced)4
hmp-crm-serviceguest (read-model)3

The 79-figure in the audit covered just the two highest-density services; the actual platform-wide count is closer to 100 sites. For property-1 launch only the 10 highest-risk are required; the rest are tracked as a multi-quarter cleanup.


§3 — Risk classification​

Each site is scored on:

  • Mutation type: read-only (low) / read-then-write (medium) / write directly to foreign table (high — illegal)
  • Sync staleness sensitivity: 5-minute lag tolerable (low) / not tolerable (high)
  • Blast radius on stale read: cosmetic display drift (low) / wrong tax / wrong rate / wrong invoice (high)
  • Has read-model sync consumer?: yes (lower risk) / no (higher risk)

10 HIGHEST-RISK sites (W3 migration target)​

#SiteWhy high-risk
1reservation-service/booking-engine.service.ts createBooking reading prisma.property for state code, GST settings, currencyDirect billing impact — wrong state code → wrong GST split
2reservation-service/booking-engine.service.ts createBooking reading prisma.guest — HYBRID Pattern A primary + Pattern D fallbackPrimary path is GuestServiceClient.findOrCreateGuestByEmail (canonical, Pattern A). Local-prisma read is a deliberate Pattern D resilience fallback for guest-service outages — accepts some staleness rather than blocking a paid booking. Verified 2026-05-06; cast cleaned up.
3reservation-service/reservations.service.ts findOne include: { guest: true } — Pattern DThe reservation detail fetch loads many includes (property, guest, company, group, roomReservations, folios, payments, reservationHistory). A per-call GuestServiceClient.getGuest fan-out would double the latency of every detail-page load. Local Guest row is synced via ReadModelSyncModule; drift detection is tracked separately as a scheduled smoke-test pass. xPrisma cast removed 2026-05-06.
4frontdesk-service/checkin/checkin.service.ts xPrisma.reservation — Pattern D + drift defenseThe Reservation model is in frontdesk's local schema as a synced read-model. xPrisma cast removed. Safety-critical concern (stale cancellationStatus → accidentally checking in a cancelled guest) addressed by an explicit canonical-status check via ReservationServiceClient.getReservation AFTER the local CONFIRMED gate; drift surfaces a BadRequestException with "Read-model drift detected". Service-client outage logs WARN and proceeds (local CONFIRMED + canonical unreachable keeps the front desk operational). Verified 2026-05-06.
5frontdesk-service/folios/folios.service.ts xPrisma.invoiceSequence.upsert — MISCLASSIFIEDThe InvoiceSequence model is OWNED by frontdesk-service (prisma/schema.prisma:356). The xPrisma cast was a type-erasing convenience, NOT a cross-service hop. Verified 2026-05-06 — replaced with the typed this.prisma.invoiceSequence reference; no migration needed.
6frontdesk-service/night-audit/night-audit.service.ts xPrisma.reservation + xPrisma.maintenanceRequest — Pattern DBoth Reservation and MaintenanceRequest are in frontdesk's local Prisma schema as synced read-models. Night-audit iterates hundreds of in-house reservations per run; per-reservation HTTP fan-out via ReservationServiceClient.getReservation would inflate audit time from seconds to minutes. xPrisma casts removed (3 sites: openMaintenance.count, reservation arrivals.count, reservation departures.count). Drift detection on the safety-critical close-path is a separate scheduled smoke test. Verified 2026-05-06.
7channel-service/intermediary/channel-booking-normalizer.service.ts prisma.roomType.findFirst + prisma.ratePlan.findFirst — DONE 2026-05-06 (Pattern A)Replaced with propertyClient.getRoomTypes + listRatePlansForBookingEngine filtered by code (5-min Redis cache via BusinessCacheService). 14/14 normalizer specs pass.
8channel-service/sync/sync.service.ts prisma.roomType.findMany for outbound ARI push — DONE 2026-05-06 (Pattern A)Replaced applyAllocationCaps roomType lookup with propertyClient.getRoomTypes filtered by code list. 30/30 sync.service specs pass.
9frontdesk-service/cform/cform.service.ts xPrisma.reservation.findUnique — PATTERN D (read-model + sync hardening)The Reservation model is in frontdesk's local Prisma schema as a synced read-model. C-Form reads many fields (roomReservations, property, confirmationNumber, arrivalDate, departureDate, adults, children, internalNotes, formCDataEncrypted) — a per-call HTTP fetch via ReservationServiceClient.getReservation would inflate the C-Form path's latency AND that client's slim DTO does not carry these fields. Verified 2026-05-06 — xPrisma cast removed; Pattern D drift detection is tracked as a separate hardening pass alongside other read-model paths.
10reservation-service/reservations.service.ts xPrisma.property reads for GST tax-rate flag + cancellation policyEach booking calc carries stale property tax config → wrong invoices

LOWER-RISK sites (post-launch backlog)​

  • All read-model Guest lookups for display purposes (read-model sync gap = stale display only).
  • Room / RoomType lookups in housekeeping (sync consumer is robust).
  • Cross-service Property reads for branding fields (synced and rarely mutated).
  • Banquet's Guest read for event contacts (low blast radius).

§4 — Migration approach per site​

Pattern A: Replace with ServiceClient.get* + 5-min cache​

Most read-only sites become a one-line swap:

Before:

const property = await this.xPrisma.property.findUnique({
where: { id: propertyId },
select: { tenantId: true, gstin: true, state: true },
});

After:

const property = await this.propertyClient.getProperty(propertyId);
// property is cached 5 minutes via BusinessCacheService inside the client

The client already returns the same shape (DTOs in @hmp/api-contracts) so no consumer-side reshape needed.

Pattern B: Replace with explicit-tenant client call​

When the caller has the propertyId but needs the canonical property's tenantId for downstream auth/scoping (Pattern A's behavior, but the client must enforce tenant-isolation explicitly):

Before:

const room = await this.xPrisma.room.findUnique({ where: { id: roomId } });

After:

const room = await this.propertyClient.getRoom(roomId, {
'x-tenant-id': callerTenantId,
'x-property-id': callerPropertyId,
});

The receiver-side assertPropertyTenant confirms the room belongs to the caller's property.

Pattern C: Replace direct write with explicit endpoint​

Illegal today:

await this.xPrisma.reservation.update({ where: { id }, data: { ... } });

Required:

await this.reservationClient.updateReservation(id, dto);

The owning service's API enforces business rules + audit + events on every write. Cross-service write-through-cast bypasses all of that.

Pattern D: Keep read-model + harden sync (deferred sites only)​

For sites where Pattern A would add unacceptable latency to a hot path (e.g. dashboard pagination over 200 reservation rows where each carries a Guest row), keep the local read-model BUT:

  • Add a replay-drift smoke test that reads the local row + the canonical via service-client and asserts equality.
  • Run that test in CI on every PR.
  • Add an Operations Hub tile that surfaces consumer-lag (last-event-processed timestamp per topic).

§5 — Migration sequence​

Phase 1 — W3 (this 2-week sprint, 10 sites)​

The 10 high-risk sites in §3 above. Each gets its own PR with:

  • Service-client call replacing the xPrisma cast.
  • One unit test asserting the new path is hit.
  • One integration test asserting the receiver-side assertPropertyTenant rejects cross-tenant reads.
  • Replay-drift test where applicable.

Estimated: 0.5 day per site = 5 engineer-days.

Phase 2 — Property-1 launch + 4 weeks (~30 medium-risk sites)​

Read-only sites where staleness is unacceptable but blast radius is small (e.g. dashboard search filters using guest data). Migrate per-service with the same per-PR pattern.

Estimated: 0.25 day per site × 30 = 7.5 engineer-days.

Phase 3 — Post-launch quarter 1 (~40 low-risk sites)​

Read-model lookups for display only. Migrate as Pattern D (keep read-model + harden sync) rather than full Pattern A — preserves performance, just adds drift detection.

Estimated: 0.1 day per site × 40 = 4 engineer-days, plus 2 days for the drift-detection harness.

Phase 4 — Post-quarter 1 (the long tail)​

Banquet, spa, asset-service, notification-service residuals. These are mostly read-only and synced; can be deferred indefinitely as long as the drift-detection harness is in place.


§6 — Hard bans (effective immediately, code review gate)​

Add to ESLint config or a pre-commit hook:

  1. No new (this.prisma as any).foreignModel casts. Detect via lint rule no-xprisma-cast.
  2. No new xPrisma field on services. Detect via lint rule no-xprisma-field.
  3. No new direct prisma.<model> access where <model> is not in the service's own prisma/schema.prisma. Detect via build-time check.

CI must fail if any of these appear in a new commit.


§7 — Cross-cutting infrastructure required​

Before Phase 1 can proceed, the following must be in place:

  • @hmp/service-clients package published (already shipped).
  • BaseServiceClient with INTERNAL_SERVICE_TOKEN injection + AsyncLocalStorage tenant/property forwarding (already shipped).
  • Receiver-side assertPropertyTenant helper (already shipped).
  • BusinessCacheService 5-min Redis cache (already shipped).
  • NEW: replay-drift test harness — a Jest matcher that reads both the local read-model and the canonical via service-client and asserts equality. Estimated 1 engineer-day.
  • NEW: Operations Hub tile showing per-topic consumer-lag (last-event-processed timestamp). Estimated 1 engineer-day.
  • NEW: ESLint rules + CI gate for the §6 hard bans. Estimated 0.5 engineer-day.

Total prep: 2.5 engineer-days. Add to Phase 1.


§8 — Out of scope for this plan​

  • Renaming xPrisma to a less-obfuscated identifier. The name is a known anti-pattern marker; renaming would hide it from grep and code review.
  • Migrating to a microservice-mesh / GraphQL federation / RPC framework. The platform's choice is HTTP + service-clients; this plan assumes that's permanent.
  • Schema-level enforcement (e.g. row-level security, foreign-key enforcement across DBs). RLS is a separate hardening pass.

§9 — Acceptance criteria​

Phase 1 (W3 sprint) is "done" when:

  • 10 sites in §3 each have a service-client call replacing the xPrisma cast, with unit + integration tests.
  • Replay-drift harness in @hmp/test-utils passes for every read-model the platform still uses.
  • Operations Hub tile renders consumer-lag for at least the 4 highest-traffic topics (reservation.created, folio.payment.received, room.status.changed, night.audit.completed).
  • ESLint rules + CI gate are merged and red-on-violation.
  • CLAUDE.md in each touched service updated with the new pattern.

Phase 2/3/4 are tracked separately; they don't block property-1 launch.


Document owner: Engineering lead. Review weekly during W3 sprint; after Phase 1 ships, update with the actual site count + lessons learned.