Skip to content

ADR-064: Consolidated Neon database — schema-per-domain isolation

Status Accepted
Date 2026-05-15
Deciders CTO, Head of Platform Engineering
Affects repos bank-core, bank-kyc, bank-aml, bank-payments, bank-credit, bank-app, bank-platform

Status: Accepted — 2026-05-15 Supersedes: ADR-052 (Neon database naming — bank_ prefix with snake_case domain abbreviation)

Context

ADR-052 established one Neon Postgres database per system domain, resulting in six operational databases: bank_core, bank_kyc, bank_aml, bank_payments, bank_credit, bank_app. The rationale was domain isolation — each system domain would be independently deployable and independently scalable with no shared database failure surface.

In practice, this architecture creates compounding complexity with no corresponding benefit:

Cross-domain reads require cross-database connection plumbing. Any module that needs data from another domain must maintain a second (or third) Postgres connection pool pointed at a different database, obtain a separate set of credentials from Secrets Manager, and manage cold-start latency for each pool. As the application layer (SD08) adds features — payments history on an account screen, KYC status alongside a credit decision, notification preferences fetched at dispatch time — every new cross-domain read adds another connection pool to cold-start. This is already visible: MOD-027 (affordability) and MOD-128 (credit bureau) both maintain a bank_kyc cross-database connection alongside their own bank_credit connection. A bank-platform counter-proposal (2026-05-15) would have added a third pattern for MOD-063 reading bank_app.

The database boundary does not enforce modularity. Modules are already the modularity boundary: each module owns its schema, controls its migrations, publishes its view contracts, and deploys independently. The modular isolation does not require separate databases — it requires schema ownership, role-based access control, and contract discipline. All of these are achievable, and already practised, within a single database.

Joins across domains are not possible cross-database. SQL joins between payments.payments and app.customer_sessions, or between kyc.cdd_tier_assignments and credit.loan_applications, require either a cross-database federated query (Postgres foreign data wrappers — not supported in Neon serverless), an application- layer join (two queries, assembled in code), or replicating data locally. All three options are worse than a native SQL join on co-located schemas. As features mature and analytics dashboards expand, this becomes a recurring tax on every feature that touches more than one domain.

SD06 and Snowflake already demonstrate the correct split. Snowflake (SD06) is the analytical store — cross-domain analysis and reporting run there, on replicated data via MOD-042. Postgres is the operational store — it should contain all operational data in one place so operational queries can be expressed naturally. This is a clean separation: Postgres for the hot path, Snowflake for analytics. A fragmented Postgres does not serve either goal well.


Decision

All system domains share a single Neon Postgres database. The database is named bank. Schema-per-domain (and schema-per-module where a module warrants it) replaces database-per-domain as the isolation boundary.

Database

Item Value
Database name bank
Hosting Neon serverless Postgres, ap-southeast-2 (ADR-024)
Branching One Neon branch per environment: dev, uat, prod
Connection pooling PgBouncer transaction-mode pooler endpoint for all Lambda connections
Direct endpoint CI-only (Flyway migrations); never used by application code

Schema map

Every schema that previously existed in a domain-specific database continues to exist unchanged, now within the single bank database.

Schema(s) Domain Owned by
accounts, core SD01 Core Banking bank-core
party, banking, kyc SD02 KYC Platform bank-kyc
aml SD03 AML Monitoring bank-aml
payments SD04 Payments bank-payments
credit SD05 Credit bank-credit
app, notifications SD08 App bank-app
ftp SD01/SD06 (FTP engine) bank-core / bank-risk-platform

SD06 (bank-risk-platform) uses Snowflake as its primary store (ADR-046) and has no schema in bank. SD07 (bank-platform) owns the bank database provisioning (MOD-103) but stores its own operational state in DynamoDB and S3 (ADR-025).

Role model

Role-based access control enforces schema ownership within the single database. The pattern per module:

Role Grants Used by
{schema}_app_user SELECT, INSERT, UPDATE, DELETE on own schema tables Lambda application connections (pooler endpoint)
{schema}_migrate_user USAGE + CREATE on own schema, ALL on own schema tables Flyway migrations in CI (direct endpoint)
{schema}_readonly SELECT on own schema tables and published views Cross-domain consumers (see below)

MOD-103 provisions all roles and grants at database bootstrap. Individual modules run schema-scoped Flyway migrations.

Secrets Manager paths

Path Content
bank-neon/{env}/{schema}_app_user username + password for {schema}_app_user role
bank-neon/{env}/{schema}_migrate_user username + password for Flyway CI
bank-neon/{env}/{schema}_readonly username + password for cross-domain reads

All secrets connect to the single bank database at the host read from SSM (/bank/{env}/neon/pooler-host). The database name field in every connection string is bank.

SSM paths (connection infrastructure)

Unchanged from ADR-052:

SSM path Value
/bank/{env}/neon/pooler-host PgBouncer endpoint (all Lambda application connections)
/bank/{env}/neon/direct-host Non-pooled endpoint (Flyway CI only)

Cross-domain reads

With all schemas co-located, cross-domain reads are schema grants within the same database connection — no separate connection pool, no second set of credentials.

The governing rule from ADR-052 is preserved and simplified:

A module may read from another module's schema if and only if the owning module has published an explicit view contract for that purpose.

A published view contract means: - The owning module defines a named view (e.g. kyc.cdd_tier_readable). - The view is documented in the owning module's design doc with column contract and stability guarantee. - The owning module's migration grants SELECT ON {view} to the consuming module's {schema}_app_user role (or a dedicated cross-domain role if multiple consumers). - The consuming module's pre-build confirmation lists the view by name.

What changes from ADR-052: the consuming module uses its own existing connection pool (same database, same connection string). No second getKycReadonlyPool(), no second Secrets Manager lookup at cold start, no second connection to initialise. The query runs on the connection the module already has open.

When to use each cross-domain read pattern:

Pattern Use when
Published schema view (direct SQL) Synchronous on-path read; joins or aggregations across schemas; latency-sensitive. No extra infrastructure.
HTTP API / Lambda invoke The owning domain must apply business logic, rate-limit, log the access, or enforce an entitlement check that cannot be expressed in a view.
Local cache from CDC / events Asynchronous or high-frequency read; eventual consistency acceptable; owning domain replicates via EventBridge.

Direct cross-table reads (bypassing the published view contract) remain prohibited.

Migration from multi-database to single database

All currently deployed modules connect to domain-specific databases. Migration to the single bank database is a single wave coordinated by MOD-103:

  1. MOD-103 provisions the bank database (Neon branch per env) and runs all schema-creation migrations in dependency order.
  2. Each domain's data is migrated using pg_dump / pg_restore schema-by-schema within a maintenance window. Neon's branch-copy capability can be used for zero-downtime migration testing.
  3. Secrets Manager secrets are updated per the new path convention.
  4. Modules are redeployed with updated DATABASE_NAME=bank environment variable.
  5. Old domain-specific databases are decommissioned once all modules confirm healthy against the consolidated database.

New modules (not yet deployed) build against the consolidated bank database from day one. Module design docs must reference the bank database and the schema-level secrets path convention.


Alternatives considered

Keep one database per domain; improve cross-domain access with Postgres foreign data wrappers (FDW). Neon serverless Postgres does not support FDW between separate Neon databases. This option is not available without migrating off Neon, which would be a larger disruption than consolidation.

Keep one database per domain; accept application-layer joins. This requires two database round-trips per cross-domain join, adds application-layer code to assemble result sets, and prevents the query planner from optimising across the join. As the number of cross-domain features grows, the accumulated complexity exceeds the cost of consolidation.

Keep one database per domain; add a read-replica DB that aggregates all schemas. A separate "read-all" database fed by logical replication from each domain database. This adds a third tier (operational DB → read-all DB → application), doubles the replication lag complexity, and still does not allow writes or transactional operations spanning domains. Over-engineered for the actual need.

Schema-per-domain within one database, no view contracts — open access. Considered and rejected. Without published view contracts, any module can read any table in any schema with no governance. Schema refactors silently break undiscovered consumers. The view contract discipline (retained from ADR-052) is cheap to maintain and provides the discoverability that the open-access model lacks.


Consequences

Positive: - SD08 (app layer) can join across payments, kyc, accounts, and app schemas in a single SQL query with full query-planner optimisation. Customer-facing screens that aggregate data across domains are straightforward to implement. - Cross-domain reads require no additional cold-start overhead. A module reading from another schema pays zero additional connection cost — it uses its existing pool. - MOD-063 (notification orchestration) reads app.notification_channel_preferences directly, with a schema grant, in its existing bank connection. The cross-database connection pool and the associated Secrets Manager secret it would otherwise have needed are eliminated entirely. - MOD-027 and MOD-128 (credit modules reading KYC views) can drop their bank_kyc cross-database connection pool once migrated. One less cold-start dependency per Lambda. - Operational dashboards, internal tooling, and DBA queries can join across all domains without ETL. Snowflake (SD06) remains the analytical store for reporting and ML features; Postgres consolidation does not change that split. - MOD-103 manages one database instead of six. Branch management, monitoring, backup policy, and cost attribution are simpler.

Negative / risks: - A schema bug in one domain's migration cannot be isolated to a single database. Mitigation: schema ownership per module remains enforced; migrations run in CI against the dev branch before promoting to uat/prod; Neon branch-per-env means a bad migration in dev does not affect prod. - One Neon project database is a single failure domain for all operational data. Mitigation: Neon's architecture is serverless with built-in replication and automatic failover; the existing RPO/RTO SLAs (NFR-019) are maintained by Neon infrastructure, not by having separate databases. - Migration wave requires a coordinated maintenance window across all repos. Mitigation: Neon branch-copy allows dry-run of the migration; the wave can be executed environment-by-environment (dev → uat → prod) with rollback at each stage.


Module-level contract discipline within a domain

The schema boundary is the repo boundary, not the module boundary

A domain schema (e.g. app.*) is owned by one repo (e.g. bank-app). Multiple modules within that repo share the schema as co-tenants. The schema enforces isolation between domains — the Postgres role model prevents app_app_user from reading banking.* without an explicit grant. It does not, by itself, enforce isolation between modules within the same domain.

The module remains the authoritative unit of ownership, contract, and deployment. The schema is the operational boundary at the domain level. These are different levels of isolation serving different purposes.

The published view contract applies within a domain

The rule from the cross-domain section is not domain-specific:

A module may read from another module's schema — or another module's tables within the same schema — if and only if the owning module has published an explicit view contract for that purpose.

Within app.*, MOD-049 must not query app.notification_channel_preferences directly even though both modules share the app schema. It reads app.notification_channel_preferences_readable — the published view — exactly as MOD-063 does from outside the domain.

Within a domain, this contract is enforced by:

  1. Migration ownership. Each module's tables are created in migrations named with the module ID (e.g. V001__mod072_initial.sql). The owning module is declared and visible in version control.

  2. Published views as the contract surface. Consuming modules list the view name in their pre-build confirmation. Code review rejects reads of another module's base tables — the view is the only sanctioned read path.

  3. Integration tests. Test suites that read another module's base tables directly will break if that table moves to a dedicated schema. This provides a mechanical catch for undeclared dependencies.

The difference from cross-domain reads is the enforcement mechanism, not the rule: cross-domain reads are rejected by Postgres if no grant exists; within-domain reads rely on code review and test coverage. Both apply the same published view discipline.

Schema-per-module is opt-in for warranted cases

The schema-per-module option exists and is supported by the role model convention ({schema}_app_user, {schema}_migrate_user). A module should get its own schema when any of the following apply:

  • Its data model is large and self-contained with no legitimate shared table access from sibling modules.
  • It has an independent lifecycle — it may be replaced or extracted to a different repo without affecting siblings.
  • The data is sensitive enough to warrant database-level access enforcement rather than convention (e.g. authentication tokens, cryptographic key references).
  • It publishes views that are consumed by many modules across the domain, and a dedicated schema makes the contract surface cleaner to document and govern.

Where schema-per-module is used, the module design doc must state the reason. The schema is provisioned by MOD-103 alongside the domain schema.

Current warranted candidates: MOD-007 (account state machine, bank-core) and MOD-068 (authentication, bank-app) both meet the sensitivity and self-containment criteria. Both are candidates for promotion to dedicated schemas (accounts_state.* and auth.* respectively) in a future migration.

Summary: two levels, two mechanisms

Level Boundary Enforced by
Domain / repo Schema (app.*, payments.*, kyc.*) Postgres role grants — hard rejection
Module within domain Published view contract Migration ownership + code review + integration tests
Module warranting isolation Dedicated schema (auth.*, accounts_state.*) Postgres role grants — hard rejection

The module is the contract boundary. The schema is the enforcement mechanism where the operational cost justifies it.


All ADRs Compiled 2026-05-22 from source/entities/adrs/ADR-064.yaml