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:
- MOD-103 provisions the
bankdatabase (Neon branch per env) and runs all schema-creation migrations in dependency order. - Each domain's data is migrated using
pg_dump/pg_restoreschema-by-schema within a maintenance window. Neon's branch-copy capability can be used for zero-downtime migration testing. - Secrets Manager secrets are updated per the new path convention.
- Modules are redeployed with updated
DATABASE_NAME=bankenvironment variable. - 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:
-
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. -
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.
-
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