Backend Data Architecture — Schema Isolation, MikroORM, Multi-Tenancy¶
What this covers: how the Acme Platform services store data. One PostgreSQL instance, one schema
per bounded context (ADR-0013), one MikroORM EntityManager per service configured by a shared
factory (@acme/mikro-orm), migrations owned and run per service, and a fail-closed global
tenant filter that is the last line of defence for multi-tenant isolation. The most expensive
lesson in this section is at the end: the fail-closed filter is global and default-on, which
means every query issued from a forked or non-request-scoped EntityManager — event consumers,
seeders, cron jobs, outbox pollers — must explicitly establish tenant context or explicitly
disable the filter. Getting that wrong does not leak data; it silently kills the feature.
1. One database, one schema per bounded context¶
ADR-0013 (Per-BC PostgreSQL Schema Isolation) makes each bounded context own a PostgreSQL
schema inside a single database. The goal is that extracting a service later is a configuration
change (point DATABASE_URL somewhere else) rather than a schema refactor.
flowchart LR
subgraph Identity["Identity BC"]
AUTH["auth-service"]
USER["user-service"]
end
subgraph PlatformBC["Platform BC"]
TEN["tenant-service"]
end
subgraph TradingBC["Trading BC"]
TRD["trading-service"]
INV["inventory-service"]
end
subgraph FinanceBC["Finance BC"]
ACC["accounting-service"]
COM["commission-service"]
end
subgraph OtherBC["Comms / Compliance / Analytics / AI"]
NTF["notification-service"]
DOC["document-service"]
AUD["audit-service"]
REP["reporting-service"]
AIS["ai-service"]
end
AUTH --> S1["schema: auth"]
USER --> S2["schema: identity"]
TEN --> S3["schema: platform"]
TRD --> S4["schema: trading"]
INV --> S5["schema: inventory"]
ACC --> S6["schema: accounting"]
COM --> S7["schema: commission"]
NTF --> S8["schema: notification"]
DOC --> S9["schema: document"]
AUD --> S10["schema: audit"]
REP --> S11["schema: reporting"]
AIS --> S12["schema: ai"]
AUTH -.-> OBX["schema: platform_outbox"]
TRD -.-> OBX
ACC -.-> OBX
COM -.-> OBX
Takeaways:
- Schema is set once, in the service's config factory. Each service exports
<svc>MikroOrmOptions(entities, clientUrl)returning{ schema: 'trading', … }, which the sharedcreateMikroOrmConfig()turns into MikroORMOptions. There is no per-query schema selection anywhere. - No cross-schema foreign keys. Cross-BC references are UUID columns, never
@ManyToOne. Cross-BC reads go through the owning service's internal API or through an event — never through SQL against another schema. platform_outboxis the one deliberately shared schema. The transactional outbox (ADR-0018) must commit in the same transaction as the business write, so it currently lives in a shared schema that every publishing service can write to. ADR-0022 records this as the pre-extraction prerequisite: before any service moves to its own PostgreSQL instance, its outbox table must move into its own schema first, or the atomicity guarantee breaks across databases.- The Terraform service catalogue has known naming drift. The infra catalogue and the
platform-pg-bootstrapchart historically declaredschema: tenant/schema: userwhere the migrations and the running pods useplatform/identity. ADR-0037 Amendment 14 (D1-3 schema/role reconciliation) settled this: runtime and migrations are the source of truth; the bootstrap chart is the lagging config, and the fix is gated behind the connection-cutover slice. The PostgreSQL roles (auth_user,tenant_user,user_user) were never wrong, so nothing running is affected.
Physical layout:
platform_dev (PostgreSQL 16, Flexible Server)
├── auth — credential, refresh_token, session, mfa_config, oidc_provider, oidc_mapping
├── platform — tenant, tenant_config, platform_setting
├── identity — user, role, permission, membership
├── trading — deal, deal_line, processed_event, idempotency_key, parked_message
├── inventory — stock, reservation, movement, processed_event
├── accounting — invoice, erp_token, erp_posting_job
├── commission — commission_period, commission_record, adjustment
├── notification — in_app_notification, email_outbox_projection
├── document — document, document_version
├── audit — audit_entry
├── reporting — read-model projections
├── ai — signal, agent_run
└── platform_outbox — outbox_entry (SHARED — every publisher writes here)
Invariant encoded: a service may open exactly one schema. A raw em.execute() mentioning
another BC's schema name is a boundary violation, and ADR-0022 Phase 0 adds a pre-commit hook that
greps for exactly that.
2. The tenant-scoped entity contract¶
Every tenant-owned entity extends TenantBaseEntity from @acme/mikro-orm. Platform-level tables
(Tenant, TenantConfig, Role, Permission, PlatformSetting, SuperadminAuditLog) do not —
they are listed in TENANT_EXEMPT_ENTITY_NAMES and may carry the @TenantExempt() decorator.
erDiagram
TENANT_SCOPED_TABLE {
uuid id PK "v4, generated in app"
uuid tenant_id "NOT NULL, indexed, fieldName tenant_id"
timestamptz created_at "onCreate"
timestamptz updated_at "onCreate and onUpdate"
}
OUTBOX_ENTRY {
uuid id PK "uuid v7 - time-ordered"
varchar entry_type "DOMAIN_EVENT or JOB"
varchar event_type "dotted event name or queue name"
jsonb payload "full DomainEvent envelope"
varchar status "PENDING PUBLISHING PUBLISHED FAILED"
timestamptz created_at "idx_outbox_pending on status+created_at"
}
PROCESSED_EVENT {
text consumer PK "inbox dedup ledger"
text event_id PK "opaque string - NOT uuid typed"
timestamptz processed_at
}
IDEMPOTENCY_KEY {
uuid tenant_id "UNIQUE with endpoint+key"
text endpoint
text key
text payload_hash "409 on reuse with different hash"
jsonb stored_response
timestamptz expires_at
}
PARKED_MESSAGE {
uuid id PK
text reason "domain-level quarantine"
jsonb payload
timestamptz parked_at
}
Notes that matter:
tenant_idisNOT NULLand indexed on every tenant-scoped table. ADR-0029 fixes the SUPERADMIN model: cross-tenant access is granted by aplatformScopeflag, never by writing aNULLtenant. There is no such thing as a legitimately unscoped business row.TenantBaseEntitydeliberately carries no@Filter('tenant')decorator. MikroORM dedupes filters by name in config → EM → entity order, so a config-level filter always wins and an entity-level one would be permanently dead code implying protection it does not provide. The config-level registration is the single source of truth.ProcessedEvent(the inbox, ADR-0072) is intentionally not aTenantBaseEntity: its identity is(consumer, event_id)and it holds no tenant-owned data.event_idistext, notuuid— event ids are opaque strings and auuidcolumn would reject non-UUID ids and break dedup silently.- Value objects reach the database through MikroORM custom types:
DecimalType(Big⇄numeric, and it throws on a raw JSnumberto prevent float precision loss),EmailTypeandTenantSlugType(lowercase-normalising on write),HashedPasswordType(pass-through, redacted intoString()).
3. Unit of Work, Identity Map, and the fork-per-operation choice¶
MikroORM is a Data Mapper ORM: EntityManager owns an Identity Map and a Unit of Work; nothing
hits the database until flush(). The platform wraps that in TenantEntityManager, whose
defining implementation detail is that every read and write forks a fresh EntityManager.
sequenceDiagram
autonumber
participant C as Caller
participant TEM as TenantEntityManager
participant ALS as TenantContext (AsyncLocalStorage)
participant F as em.fork() - fresh UoW + Identity Map
participant PG as PostgreSQL
C->>TEM: find(Deal, where)
TEM->>ALS: getStore()
alt no store
ALS-->>TEM: undefined
TEM-->>C: throw MissingTenantContextError
else tenant store
ALS-->>TEM: tenantId, isSuperAdmin
TEM->>F: fork()
TEM->>F: setFilterParams tenant = tenantId
TEM->>F: find with the tenant filter option set
F->>PG: BEGIN
PG-->>F: ok
F->>PG: SET LOCAL app.tenant_id to the tenant uuid
F->>PG: SELECT ... WHERE tenant_id = $1
PG-->>F: rows
F-->>C: entities managed by the FORK
end
Takeaways:
- Isolation is per operation, not per request. Each
find/findOne/count/findAndCount/persist/remove/createQueryBuildercall gets its own fork, its own Identity Map, and its own filter params. Twofindcalls in the same request return entities from different identity maps — object identity is not shared and change tracking on a returned entity does not accumulate into a laterflush(). persist()andremove()flush their own fork immediately;flush()on the wrapper flushes the underlying EntityManager, not any fork. TreatTenantEntityManageras a scoped-repository facade, not as a long-lived Unit of Work.- Writes are validated before they are stamped.
persist()refuses three cases outright: a SUPERADMIN write with no explicit non-emptytenantId(InvalidTenantWriteError), a tenant context whose owntenantIdis empty/whitespace (InvalidTenantWriteError), and an entity that already carries a different non-emptytenantId(CrossTenantWriteError, symmetric withremove()). Only after those checks does it stamp the id. - The multi-statement outbox convention is the opposite: never fork. Repositories that must
participate in the caller's transaction — the inbox
recordOnce, the outbox publisher — take the caller's EntityManager in the constructor and never fork, so the dedup row and the state change commit or roll back together. createQueryBuilderisasyncon purpose: it awaitsqb.applyFilters(...)internally so a caller cannot forget the activation step and ship a silently unfiltered query. MikroORM v6 quirk worth remembering:addFilter(name, false)does not disable a filter — it registers a new filter whosecondisfalseand crashes at runtime. The correct v6 disable is{ [name]: false }passed toapplyFilters/ thefiltersoption.
Connection pool caveat (verified divergence): the shared factory still ships pool: { min: 2,
max: 10 }, while ADR-0022 Phase 0 item 4 specifies min: 2, max: 5 per pod to survive HPA
scale-out on a shared instance. The ADR decision has not been reflected in the factory.
4. Migrations: owned per service, run twice-guarded¶
Each service owns its migration folder; nothing is shared. Migrations are hand-written SQL inside
Migration classes (this.addSql(...)), beginning with CREATE SCHEMA IF NOT EXISTS "<bc>".
apps/platform/<service>/src/migrations/
├── Migration_001_initial.ts CREATE SCHEMA + base tables + tenant_id indexes
├── Migration_002_*.ts additive change
├── Migration_00N_*.ts
└── __tests__/ migration specs (SQL assertions)
MikroORM settings applied by createMikroOrmConfig():
migrations.path = MIGRATIONS_PATH env, default /tmp/migrations
migrations.tableName = mikro_orm_migrations (per schema)
migrations.transactional = true
migrations.safe = true (never auto-drops)
strict = true (throws on undefined filter params, unknown props)
Two runners exist, and exactly one should be active per deployment:
sequenceDiagram
autonumber
participant H as Helm chart
participant IC as initContainer - migrate-cli
participant PG as PostgreSQL
participant APP as Service container
participant MS as MigrationService - onApplicationBootstrap
H->>IC: start with DATABASE_URL, MIGRATIONS_PATH, DB_SCHEMA
IC->>IC: validate all three env vars or exit 1
IC->>PG: MikroORM.init - entities [] - discovery off
IC->>PG: getPendingMigrations
alt pending > 0
IC->>PG: migrator.up under internal advisory lock
PG-->>IC: applied
else none
PG-->>IC: no pending migrations
end
IC-->>H: exit 0
H->>APP: start main container
APP->>MS: onApplicationBootstrap
alt SKIP_BOOTSTRAP_MIGRATIONS = true
MS-->>APP: skip - init container owns migrations
else
MS->>PG: getPendingMigrations then up
MS-->>APP: fail fast on error - pod does not serve
end
MS->>APP: register 'database' health check - SELECT 1
Takeaways:
migrate-cliis a standalone Node entrypoint for a Kubernetes init container. It requiresDATABASE_URL,MIGRATIONS_PATHandDB_SCHEMAand exits1if any is missing — a misconfigured chart fails the pod instead of migrating the wrong schema.- Concurrency is handled by MikroORM's internal advisory lock, so a rolling update with N replicas does not run migrations N times.
SKIP_BOOTSTRAP_MIGRATIONS=truedisambiguates the dual path. Running both is harmless (idempotent) but produces confusing "0 pending" logs and extra connections.- Failure is fatal by design —
MigrationServicerethrows so the service never starts against a schema it does not expect. sslmode=requireis not honoured by thepgdriver from the URL — both the runtime factory andmigrate-clidetect the substring and passdriverOptions.connection.sslexplicitly.
5. The fail-closed global tenant filter¶
createMikroOrmConfig() registers exactly one filter, named tenant, with default: true. Its
cond is the enforcement point.
flowchart TD
Q["Any MikroORM SELECT / nativeUpdate / nativeDelete"] --> D0{"filter disabled for this call?"}
D0 -- "yes - tenant filter false" --> NOWHERE["no tenant predicate added"]
D0 -- "no" --> D1{"platformScope strictly true?"}
D1 -- yes --> EMPTY["cond returns empty object - SUPERADMIN cross-tenant - ADR-0029"]
D1 -- no --> D2{"tenantId truthy?"}
D2 -- yes --> SCOPED["cond returns the tenant predicate - WHERE tenant_id = :tenantId"]
D2 -- "no / empty string" --> THROW["THROW: filter active but no tenant context provided"]
THROW --> FAIL["caller sees an actionable error - never a silent unscoped query"]
Takeaways:
- Three outcomes, and the third is an exception, not an empty predicate. Returning
{}on a missing context would drop theWHEREclause and leak across tenants; throwing turns a data breach into a loud, greppable bug. This replaced an earlier crypticTypeError: reading 'platformScope'crash. - An empty-string
tenantIdfalls through to the throw.WHERE tenant_id = ''is never a valid scope. platformScope: trueis not self-authorising. The filter cannot verify the caller is a SUPERADMIN; it must always be paired with a priorPlatformScopeGuardcheck that uses the same strict=== truecomparison (truthy-but-not-truemust not bypass). The filter is the last line of defence, not the only one.- Who seeds the params in a normal request:
TenantFilterInterceptor(the lightweight, always-on default) reads theTenantContextALS store and callsem.setFilterParams('tenant', { tenantId })on the request-scoped EntityManager. It runs as an interceptor — after guards, after MikroORM'sRequestContextmiddleware has created the per-request fork.TenantGuarddoes the same thing plus tenant resolution/validation, andTenantEntityManagerdoes it per fork. - For SUPERADMIN and for unauthenticated routes the interceptor sets nothing. A SUPERADMIN cross-tenant query must disable the filter explicitly; a public route that later issues a tenant-scoped query fails closed. Both are the intended behaviour.
Request-path assembly:
flowchart LR
GW["Gateway - verifies JWT, injects x-tenant-id"] --> MW["TenantMiddleware - TenantContext.run"]
MW --> G["Guards - JwtAuthGuard, TenantGuard, PlatformScopeGuard"]
G --> I["TenantFilterInterceptor - setFilterParams"]
I --> H["Route handler / use case"]
H --> R["Repository - em.find"]
R --> F["tenant filter cond"]
F --> SQL["SELECT ... WHERE tenant_id = $1"]
H --> TX["transaction start"]
TX --> SUB["TenantSetLocalSubscriber"]
SUB --> GUC["SET LOCAL app.tenant_id to the tenant uuid"]
6. Row-level security as defence in depth — and where it actually stands¶
TenantSetLocalSubscriber is registered by createMikroOrmConfig() as a default EventSubscriber
(merged with any service-supplied subscribers so a service cannot silently drop it). On
afterTransactionStart it emits SET LOCAL app.tenant_id = ? so PostgreSQL RLS policies can
evaluate against a transaction-bound GUC.
Facts worth carrying:
afterTransactionStart, never before.SET LOCALis rejected outside a transaction, so theBEGINmust already be on the wire.- The value is interpolated, not bound. PostgreSQL's
SETcommand does not accept bind parameters in the extended-query protocol; MikroORM'sem.execute(sql, params)substitutes?client-side with proper escaping.set_config('app.tenant_id', $1, true)is the true parameterised equivalent if wire-level binding is ever required. SET LOCALis mandatory; plainSETis a leak. ADR-0037 Amendment 1 (B1 outcome — KEEP RLS) proved empirically under PgBouncer transaction pooling that session-level GUCs surviveserver_reset_queryand the next request on that backend reads with the previous tenant's identity. OnlySET LOCALorset_config(_, _, true)is permitted — a hard contract, enforced by a CI lint onCREATE POLICY/ALTER POLICYstatements.- PG 16 quirk: after
SET LOCAL … ; COMMIT,current_setting('app.x', true)returns'', notNULL. No test or assertion may useIS NULLas the "no context" sentinel. - SUPERADMIN is skipped entirely. Setting the GUC to
''would block every row rather than bypass, because the policy fixture has no empty-string branch. Database-side SUPERADMIN bypass needs a role grant (BYPASSRLSor an RLS-excluded role) that is still an open design item. - Honest gap: the only
CREATE POLICY/ENABLE ROW LEVEL SECURITYstatements found in this repository are in the POC fixture (test/poc/b1-rls-pgbouncer-matrix/rls-fixture.sql). No production service migration enables RLS today. The subscriber emits the GUC unconditionally, so the application half of the contract is live and correct, but the database half is still POC state. Application-level enforcement (the fail-closed filter) is currently the only active isolation mechanism, not one of two. Cross-tenant maintenance jobs already carry comments flagging the role grants they will need when RLS is switched on.
7. The forked-EntityManager hazard (read this before writing a consumer)¶
This is the highest-yield operational lesson in the data layer. The filter is default: true with
no entity restriction, so in MikroORM v6 it fires for every entity — including tenant-exempt
ones that have no tenant_id column at all. Only three places seed filter params, and all three
are HTTP-request-scoped: TenantGuard, TenantFilterInterceptor, TenantEntityManager.
Anything else — an AMQP consumer, an onApplicationBootstrap seeder, a @nestjs/schedule cron, a
background poller, a CLI — runs with no filter params. Wrapping the work in
TenantContext.run(...) is not sufficient: ALS carries the tenant id, not MikroORM's filter
params.
flowchart TD
START["Query from a forked / non-request-scoped EM"] --> Q1{"Is the entity tenant-SCOPED?"}
Q1 -- "No - platform or system table" --> A["Disable the filter for that call - filters false"]
Q1 -- Yes --> Q2{"Do you know the tenantId?"}
Q2 -- Yes --> B["fork, then setFilterParams tenant = tenantId BEFORE the first read"]
Q2 -- "No - genuinely cross-tenant sweep" --> C["Disable the filter AND document it - job must run as an RLS-exempt role"]
A --> CHECK["Grep the whole file for siblings"]
B --> CHECK
C --> CHECK
CHECK --> PAIRS["find / findOne / count / nativeUpdate / nativeDelete all need the same treatment"]
PAIRS --> RAW["Raw em.execute SQL bypasses filters entirely - no disable needed"]
Confirmed instances of this class of bug, all found in review or production, none caught by the test suite:
| Site | Entity | Symptom | Resolution |
|---|---|---|---|
Trading + inventory inbox recordOnce (#1625/#1626) |
processed_event (exempt) |
first findOne throws → transaction rejects → nack → DLQ; feature 100% dead |
{ filters: false } on the read |
Shared OutboxRelay (#1707/#1715) |
OutboxEntry (platform table) |
claim find throws, caught-and-logged → nothing ever published, invisibly |
{ filters: { tenant: false } } on the find and the status-writeback nativeUpdate |
Sibling OutboxReaper |
OutboxEntry |
identical defect at its own two sites, silently defeated on every relay-enabled service | same, both sites |
trading-service seed on bootstrap |
Country (tenant-scoped) |
pod crash-loop at boot: first the global-EM ban, then the filter throw | em.fork() and setFilterParams('tenant', { tenantId }) — not filters: false, which would drop real scoping |
Rules that fall out of this, in priority order:
- Tenant-exempt / platform entity → disable the filter. Tenant-scoped entity → seed the
params. Never use
filters: falseon a tenant-scoped read; it removes real protection. - MikroORM v6 applies filters to
nativeUpdateandnativeDelete, not justfind. Fixing only the read produces the worst possible outcome: entries publish but never markPUBLISHED, so they redeliver forever. - This bug class travels in pairs and in siblings. When you fix one query, grep the whole
file and directory for every ORM
find/findOne/count/nativeUpdate/nativeDeleteon the same entity, and check the sibling component (relay ↔ reaper). - Raw
em.execute()SQL is filter-exempt — advisory locks and backlog counts need no disable. - Persists and inserts are safe — filters apply to reads and native writes, not to
persist. - The test harness hides all of it. The testcontainers
setup.tsregisters a neutered tenant filter (cond: () => ({}), default: false). A green integration run does not prove the tenant-filter path. The only real guard is a testcontainer that boots the genuinecreateMikroOrmConfig()filter with no tenant context and drives the consumer end to end.
8. Outbox and inbox — the two tables that make delivery safe¶
stateDiagram-v2
[*] --> PENDING : written in the SAME txn as the business change
PENDING --> PUBLISHING : atomic claim under pg_try_advisory_xact_lock
PUBLISHING --> PUBLISHED : RabbitMQ publish + DB flush both succeed
PUBLISHING --> PENDING : publish failed, retry budget remaining
PUBLISHING --> FAILED : publish failed, retry budget exhausted
PUBLISHING --> PUBLISHING : status writeback lost - stuck by design, reaper flips to FAILED
PUBLISHED --> [*]
FAILED --> [*]
- The relay query filters on
status = PENDING, so an entry stuck inPUBLISHINGis not republished — that is the deliberate defence against duplicate delivery when a post-publish status update is lost mid-cycle.OutboxReaperis the recovery net that flips long-stuckPUBLISHINGrows toFAILED. - Single-writer election is a PostgreSQL transaction-scoped advisory lock. ADR-0022 keeps a
lock-ID registry (auth
900001, tenant900002, user900003, trading900005, inventory900006) and requires the constructor default be removed so a missing configuration is a startup failure rather than a silent collision between two services sharing an id. idis UUID v7 — time-ordered, so theidx_outbox_pending (status, created_at)scan and the primary key agree on locality.- ADR-0072 (inbox, idempotency-key and parked-message PG tables) closes the other half: the
inbox row is inserted in the same transaction as the state change, so a redelivery hits the
composite PK and the handler acks without re-applying — at-least-once delivery becomes
effectively-once processing. A lost race on the unique constraint is caught as
UniqueConstraintViolationExceptionand reported as "already processed" rather than nacking the message into the DLQ.idempotency_keydoes the same for HTTP financial mutations (409IDEMPOTENCY_KEY_REUSEwhen a key is replayed with a different payload hash);parked_messageis the domain-level quarantine for events that are validly delivered but cannot be applied. All three live in the service's own schema — never a shared infrastructure store — precisely so they stay transactional with that service's state.
9. Deciding ADRs¶
| ADR | Title | What it fixes here |
|---|---|---|
| ADR-0013 | Per-BC PostgreSQL Schema Isolation | one schema per BC, no cross-schema FKs, per-BC migrations |
| ADR-0014 | Microservices — separate binaries day one | why extraction must stay a config change |
| ADR-0018 | Transactional outbox for domain events | business write + outbox write in one transaction |
| ADR-0022 | Database instance hardening strategy | per-service PG roles, advisory-lock registry, pool tuning, phased split triggers |
| ADR-0029 | SUPERADMIN platform-scope auth | platformScope instead of a null tenant; strict === true |
| ADR-0037 | POC-gated CNPG per-BC data tier | the path that would supersede ADR-0022 |
| ADR-0037 A1 | B1 outcome — KEEP RLS | SET LOCAL only; PG16 empty-string GUC quirk; pinning caveat |
| ADR-0037 A14 | D1-3 schema/role reconciliation | runtime/migrations are the source of truth for schema names |
| ADR-0038 A | pg-bootstrap via Kubernetes Job | schema + role provisioning mechanism |
| ADR-0072 | Inbox, idempotency-key, parked-message PG tables | effectively-once processing, retry-safe mutations |