Architecture decision ADR-004

Use Single PostgreSQL Database with Typed Product Extension Tables

Accepted · 2026-05-10

Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.

Date: 2026-05-10
Status: Accepted
Deciders: Daniel Meza


Context and Problem Statement

Urza's Wallet stores financial products across three verticals — credit cards, pension funds, and home loans — each with different fields and comparison dimensions. We need a data model that:

  1. Allows vertical-specific fields (e.g., cashback_rate_pct for credit cards, return_5yr_pct for pension funds, max_ltv_pct for home loans).
  2. Supports cross-vertical queries (e.g., "all products from issuer BAC", "all products updated in the last 24 hours").
  3. Accommodates adding a fourth or fifth vertical in the future without restructuring the schema.
  4. Preserves relational integrity (foreign keys, NOT NULL constraints, type checking) per vertical.
  5. Runs efficiently on a single Railway-managed PostgreSQL instance at Phase 1 traffic.

There are three main approaches to modeling multi-type entities in PostgreSQL. The choice has significant downstream consequences for query patterns, validation, and extensibility. Kleppmann (DDIA, Ch. 2) identifies the data model as "the most impactful single decision in application design" because it determines access patterns, and access patterns determine performance.


Decision Drivers


Considered Options

  1. Single JSONB column (EAV (Entity-Attribute-Value) / document model): All vertical-specific fields stored in a product_data JSONB column on a single financial_products table.
  2. Separate tables per vertical (no base table): credit_cards, pension_funds, home_loans as fully independent tables with no shared parent.
  3. Base table + vertical extension tables (table-per-type): A financial_products table with shared fields, and per-vertical extension tables that reference it with a 1:1 FK.
  4. Separate PostgreSQL database per vertical: Each vertical has its own Railway PostgreSQL service and schema.
  5. PostgreSQL native table inheritance: Uses PostgreSQL's INHERITS clause to create subtables that share the parent's columns.

Decision Outcome

Option 3: Base table + vertical extension tables (table-per-type) is adopted.

The financial_products table stores all fields that are shared across every vertical:

Each vertical has an extension table that stores all vertical-specific fields, with a 1:1 FK (Foreign Key) to financial_products.id:

A product detail query JOINs the base table with the appropriate extension table:

SELECT fp.*, cc.*
FROM financial_products fp
JOIN credit_cards cc ON cc.product_id = fp.id
WHERE fp.id = $1 AND fp.status = 'active';

A cross-vertical issuer query hits only the base table:

SELECT vertical, COUNT(*) as product_count
FROM financial_products
WHERE issuer_id = $1 AND status = 'active'
GROUP BY vertical;

Adding a new vertical (e.g., savings_accounts) requires:

  1. A CREATE TABLE savings_accounts (product_id UUID PRIMARY KEY REFERENCES financial_products(id), ...) migration.
  2. A new zod schema in the Canonical Mapper.
  3. A new Express Router in the Vertical Router.
  4. No changes to the financial_products table or any existing vertical's code.

Positive Consequences

Negative Consequences / Trade-offs


Pros and Cons of the Options

Option Type Safety Cross-Vertical Queries Extensibility Migration Cost Notes
JSONB single column None (app-level only) Easy (single table) Easy (no migration) Low Loses DB-level validation; queries use -> operators
Separate tables per vertical High Requires UNION ALL Easy Low No shared base; duplication of shared fields
Base + extension tables (chosen) High Easy (JOIN on base) Easy (new extension table) Medium Best balance of all requirements
Separate DB per vertical High Requires aggregation service Easy High Over-engineered for Phase 1; violates ADR-001 single DB
PostgreSQL INHERITS High Possible but complex Moderate Medium INHERITS has subtle gotchas (no FK to parent, index inheritance) — not recommended for this use case

The JSONB approach was the main competing option for its migration convenience. It was rejected because: (1) financial data has precise type requirements that should be enforced at the database level, not trusted to application validation alone; (2) JSONB queries are less readable and more prone to typos in field names; (3) pg_dump and data exports become harder to inspect and audit.

PostgreSQL INHERITS was evaluated and rejected due to its documented limitations: partitioned tables created with INHERITS do not support foreign key references to the parent table, do not inherit constraints, and create confusion when querying parent tables (which include child rows). The table-per-type approach achieves the same logical hierarchy without these caveats.


References