Appearance
Database Strategy
Phase: 1 Architecture Plan Status: pre-implementation contract
Goals
- Split the legacy single Postgres schema into service-owned Postgres databases.
- Preserve public IDs and behavior during migration.
- Keep migrations deterministic and reviewable.
- Avoid distributed transactions.
- Make parity measurable against the legacy database before cutover.
Go Data Stack
Use:
pgxpoolfor connection pooling.sqlcfor typed query generation.- service-local SQL under
services/<service>/migrationsapplied bytools/migratorusingschema-up --service=<service>. - Context-aware methods for every DB call.
- Service-local transaction helpers.
Avoid:
- global DB handles outside service startup composition.
- ORM models shared between services.
- implicit tenant filters hidden in generic repository code.
- schema migrations that read legacy data or write another service database.
Schema Design Defaults
idas stable public identifier.legacy_idnullable but unique for migrated rows.organization_idon tenant-owned entities.created_at,updated_at, anddeleted_atwhere legacy behavior uses soft delete/archive.- JSONB for compatibility snapshots.
- explicit status enums represented as text plus check constraints unless a service has a strong reason for Postgres enum.
Tenant Filtering
Tenant filtering must be explicit in SQL and service APIs. Every tenant-scoped query accepts organization_id or a policy object derived from gateway auth context.
Admin all-organization access must be visible in code as a separate branch or policy, not a missing filter.
Outbox And Events
For services that publish events:
- Write domain change and outbox event in the same local transaction.
- A worker dispatches outbox records to NATS or Redis Streams.
- Consumers are idempotent by event ID.
- Event payloads include producer service, schema version, aggregate ID, organization ID when applicable, and correlation ID.
Initial required event families:
document.media.createddocx_import.job.updateddocx_import.job.completedquestion.createdquestion.updatedexam.publishedexam.assignedattempt.startedattempt.answer_savedattempt.submittednotification.requested
Backfill Tables
Each service may maintain a migration bookkeeping table:
sql
create table migration_runs (
id text primary key,
source text not null,
started_at timestamptz not null,
finished_at timestamptz,
status text not null,
source_high_watermark text,
stats jsonb not null default '{}'::jsonb
);For row-level traceability:
sql
create table legacy_id_map (
legacy_table text not null,
legacy_id text not null,
target_id text not null,
created_at timestamptz not null default now(),
primary key (legacy_table, legacy_id)
);Services can adjust the exact DDL, but the traceability requirement is mandatory.
Schema migration state is separate from backfill traceability. The shared schema runner records applied files in each target database with schema_migrations(service, filename, checksum, applied_at). Backfill reports and loaders must keep their own migration_runs/legacy_id_map evidence and must not run inside schema migration commands.
Query Review
Before cutover of any route:
- confirm tenant filters
- confirm pagination caps
- confirm ordering compatibility
- confirm soft-delete/archive handling
- confirm JSON serialization matches contract examples
- run parity queries against a legacy snapshot
Backup And Restore
Per-service database backup is required before cutover. Restore tests must be done on staging before production writes are enabled for migrated routes.
Rollback is route-level first, database restore second. Database restore should be rare because gateway can route traffic back to legacy while migrated DB remains frozen for diagnosis.