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):
| Service | Foreign models accessed | Site count |
|---|---|---|
| hmp-reservation-service | property, guest, roomType, ratePlan | 41 |
| hmp-channel-service | roomType, ratePlan, reservation | 13 |
| hmp-frontdesk-service | reservation, room, roomType, property, guest | 25 (via xPrisma cast in checkin/folios/checkout/night-audit) |
| hmp-housekeeping-service | room, roomType (read-model — lower risk) | 8 |
| hmp-guest-service | property, reservation (read-model) | 6 |
| hmp-banquet-service | guest (synced) | 4 |
| hmp-crm-service | guest (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)
| # | Site | Why high-risk |
|---|---|---|
| 1 | reservation-service/booking-engine.service.ts createBooking reading prisma.property for state code, GST settings, currency | Direct billing impact — wrong state code → wrong GST split |
| 2 | reservation-service/booking-engine.service.ts createBooking reading prisma.guest | Primary 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. |
| 3 | reservation-service/reservations.service.ts findOne include: { guest: true } | The 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. |
| 4 | frontdesk-service/checkin/checkin.service.ts xPrisma.reservation | The 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. |
| 5 | frontdesk-service/folios/folios.service.ts xPrisma.invoiceSequence.upsert | The 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. |
| 6 | frontdesk-service/night-audit/night-audit.service.ts xPrisma.reservation + xPrisma.maintenanceRequest | Both 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. |
| 7 | channel-service/intermediary/channel-booking-normalizer.service.ts prisma.roomType.findFirst + prisma.ratePlan.findFirst | Replaced with propertyClient.getRoomTypes + listRatePlansForBookingEngine filtered by code (5-min Redis cache via BusinessCacheService). 14/14 normalizer specs pass. |
| 8 | channel-service/sync/sync.service.ts prisma.roomType.findMany for outbound ARI push | Replaced applyAllocationCaps roomType lookup with propertyClient.getRoomTypes filtered by code list. 30/30 sync.service specs pass. |
| 9 | frontdesk-service/cform/cform.service.ts xPrisma.reservation.findUnique | 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. |
| 10 | reservation-service/reservations.service.ts xPrisma.property reads for GST tax-rate flag + cancellation policy | Each booking calc carries stale property tax config → wrong invoices |
LOWER-RISK sites (post-launch backlog)
- All read-model
Guestlookups for display purposes (read-model sync gap = stale display only). Room/RoomTypelookups in housekeeping (sync consumer is robust).- Cross-service
Propertyreads for branding fields (synced and rarely mutated). - Banquet's
Guestread 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
assertPropertyTenantrejects 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:
- No new
(this.prisma as any).foreignModelcasts. Detect via lint ruleno-xprisma-cast. - No new
xPrismafield on services. Detect via lint ruleno-xprisma-field. - No new direct
prisma.<model>access where<model>is not in the service's ownprisma/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-clientspackage published (already shipped). -
BaseServiceClientwithINTERNAL_SERVICE_TOKENinjection + AsyncLocalStorage tenant/property forwarding (already shipped). - Receiver-side
assertPropertyTenanthelper (already shipped). -
BusinessCacheService5-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
xPrismato 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-utilspasses 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.