02-data-and-fhirwave: W1filled18 citations

Prisma 7 — Multi-Schema DI Wiring (Main/AI/Logs/Com/Accounting)

Audiences: developer, internal

Prisma 7 — Multi-Schema DI Wiring (Main/AI/Logs/Com/Accounting)

ddx-api runs five completely separate PostgreSQL databases, each with its own Prisma schema, generated client package, and NestJS service — enforcing data isolation at the connection level rather than at the query filter level.

Business Purpose

Healthcare systems have competing data-residency requirements that cannot be satisfied by a single database:

  • Clinical data (patients, visits, prescriptions) must be isolated from AI telemetry (chat sessions, token costs, model usage) to contain blast radius from AI provider incidents.
  • Communication logs (emails, SMS, WhatsApp delivery receipts) are subject to different retention laws than clinical records — they need an independent purge schedule.
  • Audit and access logs must be write-segregated from business tables so that a compromised business-layer credential cannot retroactively delete audit evidence.
  • Accounting data (double-entry journals, fiscal periods, invoice payments) falls under financial regulations (HGB, GDPR financial records) that require its own backup cadence and access controls.

Splitting into five databases satisfies these requirements structurally: a developer injecting only PrismaService (main) cannot reach ddx_api_log even if they try — they simply do not hold a connection to it.

Business outcome → Technical mechanism: A clinic administrator exports a patient billing report. The billing service injects PrismaAccountingService — a dedicated connection to ddx_api_acc — while simultaneously the request is logged by AccessLogInterceptor writing to ddx_api_log via PrismaLoggingService. Both writes happen in parallel with zero coupling between the two clients. The main DB never touches the log row, and the log DB never sees billing data.

Audiences

  • Investor: Five isolated databases demonstrate enterprise-grade data hygiene and GDPR/HGB compliance separation — a selling point in regulated EU healthcare markets.
  • Clinical buyer (doctor/nurse/receptionist): Transparent — the five-database split never surfaces in the UI. Clinicians interact with a unified system; isolation is an infrastructure concern.
  • Developer/partner: High impact. Any new NestJS module that needs to write to a non-main database must inject the correct service (see DI Wiring section). Cross-schema joins are architecturally impossible at the Prisma level; composition must happen in the service layer.
  • Internal (ops/support): Each database has an independent backup, migration, and retention policy. Ops must run pnpm run prisma:migrate:deploy to apply all five schemas atomically, or per-schema scripts for surgical updates.

Architecture

The Five Schemas at a Glance

Schema dirPostgreSQL DBNestJS serviceModelsEnumsEnv var
prisma-main/models/ (20 files)ddx_api_mainPrismaService135104DATABASE_URL
prisma-ai/schema.prismaddx_api_aiPrismaAiService3126AI_DATABASE_URL
prisma-logging/schema.prismaddx_api_logPrismaLoggingService60LOGGING_DATABASE_URL
prisma-com/schema.prismaddx_api_comPrismaComService75COM_DATABASE_URL
prisma-accounting/schema.prismaddx_api_accPrismaAccountingService1621ACCOUNTING_DATABASE_URL

A sixth database (ddx_fhir_core) is managed exclusively by HAPI FHIR (Java/Hibernate). Never run Prisma against it — Prisma's migrate deploy will DROP all HAPI tables.

Main Schema — Split-File Layout

Unlike the other four schemas (single .prisma files), the main schema is split across 20 model files under ddx-api/prisma-main/models/. Prisma's multi-file schema feature (stable in Prisma 6+) compiles these into one logical schema at generate time. The generator output lands in node_modules/@ddx/prisma-main — the same import path that PrismaService extends.

DI Module Topology

diagram
AppModule
  └── PrismaModule (@Global)          ← ddx_api_main + ddx_api_ai + ddx_api_com + ddx_api_acc
        PrismaService                 → ddx_api_main
        PrismaAiService               → ddx_api_ai
        PrismaComService              → ddx_api_com
        PrismaAccountingService       → ddx_api_acc

  └── LoggingModule (@Global)         ← ddx_api_log ONLY
        PrismaLoggingService          → ddx_api_log

PrismaModule is @Global(), so any module in the application can inject PrismaService, PrismaAiService, PrismaComService, or PrismaAccountingService directly — no local imports needed.

PrismaLoggingService is intentionally excluded from PrismaModule. It is registered in LoggingModule (@Global()), which exports it alongside LoggingService. Business services must never inject PrismaLoggingService directly — they must go through LoggingService or AuditService. This prevents business logic from writing arbitrary data to the audit trail.

Reference: ddx-api/src/platform/prisma/prisma.module.ts:18-37 (comment block explains the exclusion explicitly).

Service Class Pattern

Every Prisma service follows the same pattern:

typescript
@Injectable()
export class PrismaXxxService
  extends PrismaClient          // extends the generated client
  implements OnModuleInit, OnModuleDestroy
{
  async onModuleInit() { await this.$connect(); }
  async onModuleDestroy() { await this.$disconnect(); }
}

The generated client for each schema is a separate npm package (@ddx/prisma-main, @ddx/prisma-ai, etc.) published into node_modules by the local output directive in each generator client {} block. NestJS's DI container manages the connection lifecycle via OnModuleInit / OnModuleDestroy.

Static connectedLogged flag: All five services carry a private static connectedLogged = false field. NestJS may instantiate the same @Injectable() 2-3 times during module initialization when a service is re-provided in multiple modules. The static flag ensures only one "connected" log line appears per process, preventing log noise from tripping monitoring alerts.

Tech Stack & Choices

Prisma version in use

ddx-api/package.json lists prisma ^6.19.1. The project documentation and agent config refer to "Prisma 7" as a forward-looking target. The patterns in place (multi-file schema, per-schema clients, datasources constructor override, $connect/$disconnect lifecycle) are compatible with both Prisma 6 and 7. The migration path from 6 to 7 is additive — the main breaking change in Prisma 7 is the shift to prisma.config.ts as the primary configuration file (replacing generator and datasource blocks inline in the schema), plus stable driver adapters (bypassing the Rust query engine for environments like Cloudflare Workers or edge runtimes). Neither driver adapters nor prisma.config.ts are currently deployed in ddx-api; the project uses standard Node.js Prisma with the Rust query engine via the Postgres native adapter.

Why five separate clients instead of one with schemas?

Prisma's multi-schema feature (PostgreSQL SET search_path) allows one client to span multiple schemas within one PostgreSQL instance. The project chose separate PostgreSQL instances (see ddx-api/CLAUDE.md database table) rather than one instance with schemas. This means:

  • Each database can run on a separate host, separate backup policy, and separate connection pool.
  • A connection-level credential separation is possible (different DB users per service).
  • A schema migration on ddx_api_ai cannot block ddx_api_main migrations.

Generated client output convention

Each generator client {} block in the schema files targets output = "../node_modules/@ddx/prisma-<name>":

  • ddx-api/prisma-ai/schema.prisma:8output = "../node_modules/@ddx/prisma-ai"
  • ddx-api/prisma-logging/schema.prisma:4output = "../node_modules/@ddx/prisma-logging"
  • ddx-api/prisma-com/schema.prisma:4output = "../node_modules/@ddx/prisma-com"
  • ddx-api/prisma-accounting/schema.prisma:9output = "../node_modules/@ddx/prisma-accounting"

The main schema uses output = "../node_modules/@ddx/prisma-main" (declared in one of its 20 model files). Services import from these package aliases, e.g. import { PrismaClient } from '@ddx/prisma-main' (ddx-api/src/platform/prisma/prisma.service.ts:7).

URL injection — constructor vs datasources override

PrismaService (main) reads DATABASE_URL from the environment implicitly via the datasource db { url = env("DATABASE_URL") } directive in the schema. The four domain-specific services (AI, Com, Logging, Accounting) use an explicit datasources constructor override reading from ConfigService, e.g.:

typescript
// ddx-api/src/ai/ai-core/prisma-ai.service.ts:27-35
constructor(private readonly configService: ConfigService) {
  super({
    datasources: {
      db: { url: configService.get<string>('AI_DATABASE_URL') },
    },
    log: ['error'],
  });
}

This lets NestJS's ConfigService validate the env variable at boot (via validate-env.ts) before the first connection attempt.

prisma.config.ts — Prisma 7 feature not yet deployed

Prisma 7 introduces prisma.config.ts as a first-class configuration file (analogous to drizzle.config.ts) that centralises migrate, generate, and studio settings. As of last_verified_date, no prisma.config.ts exists in ddx-api/. Configuration is expressed via per-schema generator + datasource blocks and the package.json scripts documented below.

TypedSQL and Client Extensions

TypedSQL (Prisma 6+) allows raw SQL files in a sql/ directory to be compiled into fully-typed query functions. Not currently used in ddx-api — raw queries are expressed via this.$queryRaw template literals in the ping() methods.

Client Extensions (replacing the deprecated $use middleware from Prisma 5) allow adding custom methods or result transformers to the client. Not currently used. The $use pattern is absent from all five service files; the project relies on NestJS interceptors (e.g. AuditInterceptor) for cross-cutting concerns rather than Prisma middleware.

Data Flow

Standard request path (business data)

diagram
HTTP Request
  └─► Controller (e.g. BillingController)
        └─► Service (e.g. BillingService)
              └─► inject PrismaAccountingService   ← ddx_api_acc
                    └─► this.payment.create(...)    ← SQL INSERT into payments table
              └─► inject PrismaService              ← ddx_api_main (if patient ref needed)
                    └─► this.patient.findUnique(...)

Cross-schema: if BillingService needs to read a patient name from ddx_api_main while writing a payment to ddx_api_acc, it holds two injected services. There is no Prisma-level join across these two databases. The service fetches from each client in sequence or parallel and assembles the result in-process.

AI session flow

TucanController → TucanService → PrismaAiService (ddx_api_ai)
  chatSession.create(...)
  chatMessage.create(...)
  tucanUsageRecord.create(...)   ← cost tracking

The AI DB stores no clinical PII — only session IDs, token counts, and model names. Clinical context (patient ID, visit ID) appears as opaque UUID columns in ChatSession with no FK constraints (no cross-DB FK in PostgreSQL across separate instances).

Logging path (decoupled)

diagram
Any inbound HTTP request
  └─► AccessLogInterceptor (global interceptor #5 in the pipeline)
        └─► LoggingService.log(...)
              └─► PrismaLoggingService (ddx_api_log)
                    └─► accessLog.create(...)

This path is completely decoupled from business services. PrismaLoggingService is NOT available for injection in business modules — only LoggingService (which wraps it) is exported from LoggingModule.

Communication dispatch flow

NotificationService → PrismaComService (ddx_api_com)
  communicationLog.create(status: PENDING)
  communicationQueue.create(scheduledAt: ...)

[cron or webhook callback]
  communicationLog.update(status: SENT | FAILED)
  communicationEvent.create(...)

Implicated Code

Schema files

  • ddx-api/prisma-ai/schema.prisma:1-14 — generator + datasource block; database ddx_clinic_fhir_ai, env AI_DATABASE_URL, output @ddx/prisma-ai
  • ddx-api/prisma-logging/schema.prisma:1-13 — generator + datasource block; database ddx_api_log, env LOGGING_DATABASE_URL, output @ddx/prisma-logging; 6 models, 0 enums (append-only, no status enums)
  • ddx-api/prisma-com/schema.prisma:1-13 — generator + datasource block; database ddx_api_com, env COM_DATABASE_URL, output @ddx/prisma-com
  • ddx-api/prisma-accounting/schema.prisma:1-15 — generator + datasource block; database ddx_api_acc, env ACCOUNTING_DATABASE_URL, output @ddx/prisma-accounting

Service files

  • ddx-api/src/platform/prisma/prisma.service.ts:1-105PrismaService extends PrismaClient from @ddx/prisma-main; static connectedLogged flag at line 23; ping() method at line 66 for health checks
  • ddx-api/src/ai/ai-core/prisma-ai.service.ts:1-72PrismaAiService extends PrismaAiClient from @ddx/prisma-ai; reads AI_DATABASE_URL via ConfigService (lines 27-35); static connectedLogged at line 24
  • ddx-api/src/communication/communication-core/prisma-com.service.ts:1-186PrismaComService; includes domain helpers cleanupOldCommunications() (line 83) and getOrganizationStats() (line 146) — service-layer logic co-located with the connection manager
  • ddx-api/src/platform/logging/prisma-logging.service.ts:1-102PrismaLoggingService extends LoggingPrismaClient; cleanupOldLogs() at line 73 reads LOG_RETENTION_DAYS (default 90 days)
  • ddx-api/src/financial/accounting/prisma-accounting/prisma-accounting.service.ts:1-94PrismaAccountingService; exports AccountingTransactionClient type alias at line 11 for typed $transaction callbacks; executeTransaction<T>() helper at line 87

Module wiring

  • ddx-api/src/platform/prisma/prisma.module.ts:1-37@Global() PrismaModule; registers PrismaService, PrismaAiService, PrismaComService, PrismaAccountingService; comment at line 19 explicitly documents that PrismaLoggingService is excluded ("scoped by design — only via LoggingModule/AuditService")
  • ddx-api/src/platform/logging/logging.module.ts:1-34@Global() LoggingModule; registers and exports PrismaLoggingService alongside LoggingService; the only module allowed to expose the logging client

package.json scripts

  • ddx-api/package.json — per-schema generate scripts: prisma:generate (main), prisma:generate:ai, prisma:generate:logging, prisma:generate:com, prisma:generate:accounting, plus prisma:generate:all (runs bash scripts/generate-all.sh). Migration equivalents: prisma:migrate, prisma:migrate:all, prisma:migrate:deploy.

Operational Notes

prisma generate — types only, not schema

Running pnpm run prisma:generate:all (or any per-schema variant) only regenerates the TypeScript types in node_modules/@ddx/prisma-<name>. It does NOT:

  • Create or alter any PostgreSQL table.
  • Apply pending migrations.
  • Reflect schema changes made after the last migration.

After adding a model or enum to a .prisma file, the workflow is:

  1. Write the model/enum in the schema file.
  2. pnpm run prisma:migrate (main) or prisma migrate dev --schema=./prisma-<name>/schema.prisma to create the SQL migration file.
  3. Commit the migration file under prisma-main/models/migrations/ (or the equivalent directory for the other schemas).
  4. On production: pnpm run prisma:migrate:deploy applies all pending migrations across all five databases.
  5. pnpm run prisma:generate:all regenerates types (runs automatically as a prebuild hook).

Project-memory pitfall: prisma generate is also triggered automatically as prebuild and prestart hooks (ddx-api/package.json), so the types are always current when the app starts. But if a developer adds a new column and forgets to write a migration, the TypeScript code will compile and the app will boot — then fail at runtime with column does not exist because the DB schema lags behind the generated types.

Schema/enum changes need hand-written SQL when using migrate deploy

prisma migrate deploy only applies files already in the migrations/ directory. For production, the developer must first run prisma migrate dev locally (which generates the SQL migration file), commit it, and then deploy. Skipping migrate dev and manually editing the DB directly will cause prisma migrate status to report "database schema is not in sync" and subsequent migrate deploy runs may fail.

The main schema uses a symlinked migrations directory

ddx-api/prisma-main/models/migrations is a symlink to ddx-api/prisma-main/migrations (auto-created by project tooling). This allows Prisma's --schema=./prisma-main/models flag to find the migrations directory correctly when the schema is spread across 20 files in a subdirectory.

Per-database retention and backup

Each database has an independent retention policy enforced by its service:

DatabaseRetention configImplementation
ddx_api_logLOG_RETENTION_DAYS (default 90)PrismaLoggingService.cleanupOldLogs()
ddx_api_comCOMMUNICATION_RETENTION_DAYS (default 365)PrismaComService.cleanupOldCommunications()
ddx_api_main, ddx_api_ai, ddx_api_accNo in-app purge — external backup/archivalManaged by prisma:backup script

Health check integration

Every service exposes a ping(): Promise<boolean> method (this.$queryRawSELECT 1``). The health/ NestJS module aggregates these pings for the /health endpoint. A failed ping for any of the five databases returns a degraded status.

Cross-schema data composition — no Prisma join

Because the five schemas live in separate PostgreSQL instances, there is no mechanism for a Prisma query to join across them. Service-layer composition is the only option:

typescript
// Example: billing report needing both patient name and payment record
const payment = await this.prismaAcc.payment.findUnique({ where: { id } });
const patient = await this.prismaMain.patient.findUnique({ where: { id: payment.payerId } });
return { ...payment, patientName: patient.fullName };

This is not a limitation but an explicit design constraint: it prevents silent data-mixing and makes cross-domain reads visible in code review.

Never run Prisma against ddx_fhir_core

The HAPI FHIR database (ddx_fhir_core, port 18080) is managed entirely by Java/Hibernate. Running prisma migrate dev against its connection string will DROP all HAPI-managed tables. There is no Prisma schema for it — access is exclusively via the FHIR REST API (FhirClientService).