Database Guide — Membership One

Overview

Membership One uses PostgreSQL 18 with Flyway for schema migrations. The database schema follows a multi-tenant model where all business tables contain an entity_id foreign key referencing the organization table.

Connection Details

Environment Host Port Database Schema
Local (Docker) localhost 5433 membership public
Dev configured 5432 membership public
Test Testcontainers dynamic membership public
Production configured 5432 membership public

Note: Local port is 5433 (not 5432) to avoid conflict with the Cash360 testdb running on 5432.

Schema Overview

Phase 0 — Seed Tables

Table Purpose Rows
currency ISO 4217 currencies 16
country ISO 3166-1 countries 34
role User roles 16
permission Permission codes 47
role_permission Role-permission mapping ~180
chart_of_account SKR03 accounts ~30
vat_rate EU VAT rates per country ~60

Phase 1 — Core Business Tables

Table Module Key Columns
organization entity name, slug, type, tier, status, settings (JSONB)
app_user auth entity_id, email, password_hash, status, failed_login_count
user_role auth user_id, role_code (composite PK)
member member entity_id, member_number, first_name, last_name, custom_attributes (JSONB)
audit_log member entity_id, target_type, target_id, action, changed_fields (JSONB)
membership_template contract entity_id, name, price, billing_cycle, features (JSONB)
contract contract entity_id, member_id, template_id, status, monthly_price
product product entity_id, name, price, vat_rate, custom_attributes (JSONB)

Entity-Relationship Diagram

erDiagram organization ||--o{ app_user : "has users" organization ||--o{ member : "has members" organization ||--o{ membership_template : "defines templates" organization ||--o{ contract : "has contracts" organization ||--o{ product : "offers products" app_user ||--o{ user_role : "has roles" role ||--o{ user_role : "assigned to" role ||--o{ role_permission : "grants" permission ||--o{ role_permission : "granted by" member ||--o{ contract : "signs" membership_template ||--o{ contract : "instantiated as" member ||--o| member : "responsible for (minors)" organization ||--o| organization : "parent (hierarchy)" audit_log }o--|| organization : "belongs to"

Flyway Migrations

Migration Naming Convention

V{PPP}__{description}.sql
  • PPP = Phase-based prefix (3 digits)
  • Phase 0: V000-V099
  • Phase 1: V100-V199
  • Phase 2: V200-V299

Current Migrations

Version Module Description
V000 core Seed tables (currency, country, role, permission, chart_of_account, vat_rate)
V001 core Seed reference data
V100 entity Create organization table
V101 auth Create app_user table
V102 auth Create user_role join table
V103 member Create member table
V104 member Create audit_log table
V105 contract Create membership_template table
V106 contract Create contract table
V107 product Create product table

Migration Location

Each module has its own migration directory:

membership-{module}/src/main/resources/db/migration/V{nnn}__{name}.sql

All migrations are discovered via classpath scanning by Flyway.

Patterns

JSONB Columns

Used for extensible data. JPA mapping:

@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
@Builder.Default
private String settings = "{}";

SQL DDL:

settings JSONB DEFAULT '{}'::jsonb

Sequences

Every table uses a dedicated sequence with allocation size 50:

CREATE SEQUENCE IF NOT EXISTS member_seq START WITH 1 INCREMENT BY 50;

JPA entity:

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_gen")
@SequenceGenerator(name = "member_gen", sequenceName = "member_seq", allocationSize = 50)
private Long id;

Note: BaseEntity uses GenerationType.SEQUENCE without explicit generator — Hibernate auto-detects the sequence name from the table name.

Optimistic Locking

Every entity has @Version via BaseEntity:

@Version
private Long version;

DDL includes:

version BIGINT NOT NULL DEFAULT 0

Audit Columns

Every table includes:

created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()

Managed by JPA auditing (@CreatedDate, @LastModifiedDate in BaseEntity).

Foreign Keys and Indexes

  • FK naming: fk_{table}_{reference} (e.g., fk_contract_member)
  • Index naming: idx_{table}_{columns} (e.g., idx_member_entity_status)
  • Unique constraint naming: uq_{table}_{columns}
  • PK naming: pk_{table}

Multi-Tenant Data Isolation

All business tables contain entity_id BIGINT NOT NULL with FK to organization(id).

Application-level enforcement: - JWT token contains tenantId (= entityId) - TenantContext (ThreadLocal) stores current tenant per request - All repository queries MUST include entityId filter - Service layer validates entityId on every read/write operation

There is no database-level row-level security (RLS) yet. Tenant isolation is enforced purely at the application layer.

Docker Setup

cd backend
docker compose up -d          # Start PostgreSQL, Redis, RabbitMQ
docker compose down           # Stop and remove containers
docker compose down -v        # Also remove volumes (reset data)

Flyway runs automatically on application startup.

Useful Queries

-- Count rows per table
SELECT relname, reltuples::int FROM pg_class
WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace
ORDER BY reltuples DESC;

-- Check migration status
SELECT * FROM flyway_schema_history ORDER BY installed_rank;

-- List all indexes
SELECT tablename, indexname FROM pg_indexes
WHERE schemaname = 'public' ORDER BY tablename;

-- Verify foreign keys
SELECT conname, conrelid::regclass, confrelid::regclass
FROM pg_constraint WHERE contype = 'f';