Skip to content

Database Architecture Overview

The system uses three separate PostgreSQL databases, each serving a distinct bounded context in the Domain-Driven Design (DDD) architecture:

Database NamePurposePrimary TablesMicroservices
app_core_dbCore operational dataresources, engagements, intakes, records, orders, reportsData Owner BC, Data Consumer BC, Scheduling BC
app_iam_dbIdentity & Access Managementusers, roles, permissions, user_roles, role_permissions, policiesIAM Service, Auth Service
app_master_dbMaster/Reference datalabels, regions, districts, sub_districts, departments, classification_codesMasterData Service

1. Domain-Driven Design (DDD) Bounded Contexts

Section titled “1. Domain-Driven Design (DDD) Bounded Contexts”

Each database represents a distinct bounded context with clear domain boundaries:

graph TB
    subgraph "app_core_db"
        A[Resource Management]
        B[Records]
        C[Scheduling]
        D[Core Operations]
    end

    subgraph "app_iam_db"
        E[User Management]
        F[Authentication]
        G[Authorization]
        H[Access Control]
    end

    subgraph "app_master_db"
        I[Reference Data]
        J[Lookup Tables]
        K[Code Sets]
        L[Standards]
    end

    style A fill:#e1f5ff
    style B fill:#e1f5ff
    style C fill:#e1f5ff
    style D fill:#e1f5ff
    style E fill:#fff4e1
    style F fill:#fff4e1
    style G fill:#fff4e1
    style H fill:#fff4e1
    style I fill:#e8f5e9
    style J fill:#e8f5e9
    style K fill:#e8f5e9
    style L fill:#e8f5e9

Benefits:

  • Clear Ownership: Each bounded context owns its data domain
  • Independent Evolution: Schemas can evolve independently
  • Reduced Coupling: Changes in one domain don’t affect others
  • Single Source of Truth: Each entity has one authoritative location

Each microservice can:

  • Scale Independently: Different databases can be scaled based on their specific load patterns
  • Deploy Independently: Database migrations don’t create cross-service dependencies
  • Fail Independently: Failure in the IAM database doesn’t bring down core operations
  • Optimize Differently: Each database can have specific indexes, partitioning, and tuning

Example: The IAM database experiences high read traffic during authentication but low writes. The Core database has constant writes (operational records) but moderate reads. Separate databases allow different optimization strategies.

Data Segregation:

  • IAM Database: Contains highly sensitive authentication data (password hashes, tokens, security policies)
  • Core Database: Contains protected operational data subject to regulatory requirements
  • Master Database: Contains non-sensitive reference data (can have different backup/encryption policies)

Access Control:

// Different database connections with different credentials
const coreConnection = {
host: process.env.DB_CORE_HOST,
username: process.env.DB_CORE_USER, // Limited to core operations
password: process.env.DB_CORE_PASSWORD,
database: 'app_core_db',
};
const iamConnection = {
host: process.env.DB_IAM_HOST,
username: process.env.DB_IAM_USER, // Only IAM service has access
password: process.env.DB_IAM_PASSWORD,
database: 'app_iam_db',
};

Different databases have different data lifecycle requirements:

DatabaseRetention PolicyBackup FrequencyArchive Strategy
Core10+ years (operational records)Every 4 hoursCold storage after 5 years
IAM2 years (audit logs)DailyDelete after 2 years
MasterIndefinite (reference data)WeeklyNever archived

Each database can make different CAP theorem choices:

  • Core Database: Prioritizes Consistency (C) + Availability (A) — Critical for operational accuracy
  • IAM Database: Prioritizes Consistency (C) + Partition Tolerance (P) — Authentication must be accurate
  • Master Database: Prioritizes Availability (A) + Partition Tolerance (P) — Reference data rarely changes

flowchart TB
    subgraph "Client Layer"
        WebApp[Web Application]
        MobileApp[Mobile Application]
    end

    subgraph "API Gateway"
        Kong[Kong API Gateway]
    end

    subgraph "Microservices Layer"
        AuthService[Auth Service]
        IAMService[IAM Service]
        DataOwnerService[Data Owner BC]
        DataConsumerService[Data Consumer BC]
        MasterService[MasterData Service]
    end

    subgraph "Database Layer"
        CoreDB[(app_core_db)]
        IAMDB[(app_iam_db)]
        MasterDB[(app_master_db)]
    end

    subgraph "Lookup/Read Models in Core"
        LabelsLookup[labels_lookup]
        RegionsLookup[regions_lookup]
        DistrictsLookup[districts_lookup]
    end

    WebApp --> Kong
    MobileApp --> Kong
    Kong --> AuthService
    Kong --> DataOwnerService
    Kong --> DataConsumerService

    AuthService --> IAMDB
    IAMService --> IAMDB
    DataOwnerService --> CoreDB
    DataConsumerService --> CoreDB
    MasterService --> MasterDB

    MasterDB -.->|Event-Driven Sync| LabelsLookup
    MasterDB -.->|Event-Driven Sync| RegionsLookup
    MasterDB -.->|Event-Driven Sync| DistrictsLookup

    LabelsLookup --> CoreDB
    RegionsLookup --> CoreDB
    DistrictsLookup --> CoreDB

    style CoreDB fill:#e1f5ff,stroke:#0288d1,stroke-width:3px
    style IAMDB fill:#fff4e1,stroke:#ff9800,stroke-width:3px
    style MasterDB fill:#e8f5e9,stroke:#4caf50,stroke-width:3px
    style LabelsLookup fill:#ffe0e0,stroke:#f44336,stroke-width:2px
    style RegionsLookup fill:#ffe0e0,stroke:#f44336,stroke-width:2px
    style DistrictsLookup fill:#ffe0e0,stroke:#f44336,stroke-width:2px

Problem: Microservices should not directly access other services’ databases, but we need master data in the core database for:

  1. SQL Reporting: Tools like Jasper Reports can’t JOIN across databases
  2. Performance: Avoid microservice calls for every query
  3. Data Locality: Keep frequently accessed data close to where it’s needed

Anti-Pattern ❌:

-- This is NOT possible in PostgreSQL
SELECT
r.reference_number,
r.first_name,
l.display_name -- labels table is in a different database
FROM app_core_db.resources r
JOIN app_master_db.labels l ON r.label_code = l.code;

We replicate read-only copies of master data into the core database. These are called Lookup Tables or Read Models (from the CQRS pattern).

sequenceDiagram
    participant Master as MasterData Service<br/>(app_master_db)
    participant EventBus as Event Bus<br/>(RabbitMQ/Redis)
    participant Core as Data Owner BC<br/>(app_core_db)
    participant Reports as Reporting Tool

    Note over Master: Admin creates new label
    Master->>Master: INSERT INTO labels
    Master->>EventBus: Emit 'label.created' event
    EventBus->>Core: Handle 'label.created' event
    Core->>Core: UPSERT INTO labels_lookup

    Note over Reports: Report needs resource with label
    Reports->>Core: SQL Query (JOIN resources + labels_lookup)
    Core-->>Reports: ✅ Result with label name

Key Principle: Master data is the Source of Truth, lookup tables are Cached Copies for read operations.


Location: app_master_db.labels

apps/masterdata/src/modules/label/entities/label.entity.ts
@Entity({
name: 'labels',
database: AppDatabases.APP_MASTER,
})
export class Label implements ITimestamp {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'varchar',
length: 10,
unique: true,
comment: 'Label code (e.g., MR, MRS, DR)',
})
code: string;
@Column({
type: 'varchar',
length: 50,
comment: 'Display label',
})
label: string;
@Column({
type: 'varchar',
length: 50,
comment: 'Display name (e.g., Mister, Mistress)',
})
display_name: string;
@Column({
type: 'varchar',
length: 50,
nullable: true,
comment: 'Abbreviated form (e.g., Mr., Mrs., Dr.)',
})
abbreviation: string | null;
@Column({
type: 'int',
default: 0,
comment: 'Display order in dropdowns',
})
display_order: number;
@Column({
type: 'boolean',
default: true,
comment: 'Is this label active?',
})
is_active: boolean;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@Column({ type: 'uuid', nullable: true })
created_by: string | null;
@Column({ type: 'uuid', nullable: true })
updated_by: string | null;
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
deleted_at: Date | null;
}

Sample Data:

-- app_master_db.labels
INSERT INTO labels (id, code, label, display_name, abbreviation, display_order, is_active) VALUES
('uuid-001', 'MR', 'Mr.', 'Mister', 'Mr.', 1, true),
('uuid-002', 'MRS', 'Mrs.', 'Mistress', 'Mrs.', 2, true),
('uuid-003', 'MISS', 'Miss', 'Miss', 'Miss', 3, true),
('uuid-004', 'DR', 'Dr.', 'Doctor', 'Dr.', 4, true);

Location: app_core_db.labels_lookup

apps/data-owner-bc/src/modules/lookup/entities/label-lookup.entity.ts
@Entity({
name: 'labels_lookup',
database: AppDatabases.APP_CORE,
})
export class LabelLookup {
/**
* Primary key is 'code' (not UUID) for efficient JOINs
*/
@PrimaryColumn({
type: 'varchar',
length: 10,
comment: 'Label code from master table',
})
code: string;
@Column({
type: 'varchar',
length: 50,
comment: 'Display label (synced from master)',
})
label: string;
@Column({
type: 'varchar',
length: 50,
comment: 'Display name (synced from master)',
})
display_name: string;
@Column({
type: 'varchar',
length: 50,
nullable: true,
comment: 'Abbreviated form',
})
abbreviation: string | null;
@Column({
type: 'int',
default: 0,
comment: 'Display order',
})
display_order: number;
@Column({
type: 'boolean',
default: true,
comment: 'Is active (synced from master)',
})
is_active: boolean;
/**
* Critical: Track when this record was last synced
*/
@UpdateDateColumn({
type: 'timestamptz',
comment: 'Last synchronization time from master',
})
synced_at: Date;
/**
* Optional: Store source UUID for debugging
*/
@Column({
type: 'uuid',
nullable: true,
comment: 'Original UUID from master table (for reference)',
})
master_id: string | null;
}

Key Differences from Master Table:

  1. ✅ Primary key is code (not uuid) — optimized for JOINs
  2. ✅ Contains only essential fields for reading (no audit fields like created_by, updated_by)
  3. ✅ Has synced_at timestamp to track freshness
  4. ✅ Stores master_id (original UUID) for traceability
  5. ❌ No deleted_at — records are hard-deleted when master marks inactive
  6. ❌ Simpler schema — no need for full audit trail

Sample Data:

-- app_core_db.labels_lookup (synced copy)
INSERT INTO labels_lookup (code, label, display_name, abbreviation, display_order, is_active, synced_at, master_id) VALUES
('MR', 'Mr.', 'Mister', 'Mr.', 1, true, '2025-01-15 10:30:00', 'uuid-001'),
('MRS', 'Mrs.', 'Mistress', 'Mrs.', 2, true, '2025-01-15 10:30:00', 'uuid-002'),
('MISS', 'Miss', 'Miss', 'Miss', 3, true, '2025-01-15 10:30:00', 'uuid-003'),
('DR', 'Dr.', 'Doctor', 'Dr.', 4, true, '2025-01-15 10:30:00', 'uuid-004');

apps/data-owner-bc/src/modules/resource/entities/resource.entity.ts
@Entity({
name: 'resources',
database: AppDatabases.APP_CORE,
})
export class Resource implements ITimestamp {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 11, unique: true })
reference_number: string;
/**
* Foreign key to labels_lookup (not labels in master DB)
*/
@Column({
type: 'varchar',
length: 10,
nullable: true,
comment: 'References labels_lookup.code',
})
label_code: string | null;
@Column({ type: 'varchar', length: 50 })
first_name: string;
@Column({ type: 'varchar', length: 50 })
last_name: string;
// ... other fields
}
-- ✅ This query works because both tables are in app_core_db
SELECT
r.reference_number,
r.first_name,
r.last_name,
l.label, -- From lookup table
l.abbreviation -- From lookup table
FROM resources r
LEFT JOIN labels_lookup l ON r.label_code = l.code
WHERE r.reference_number = 'REF001';

Result:

reference_number | first_name | last_name | label | abbreviation
------------------+------------+-----------+-------+--------------
REF001 | John | Smith | Mr. | Mr.

AspectMaster Table (labels)Lookup Table (labels_lookup)
Databaseapp_master_dbapp_core_db
Primary Keyid (UUID)code (VARCHAR)
PurposeSource of truth, full CRUDRead-only cache for queries
OwnershipMasterData ServiceData Owner BC (consumer)
Write AccessMasterData Service onlySync Service only
Audit Fieldscreated_by, updated_by, deleted_atNone (simplified)
Sync TrackingN/Asynced_at, master_id
IndexingStandard B-tree on id, unique on codeOptimized for JOINs on code
Data VolumeComplete historical dataActive records only
Update FrequencyLow (admin changes)Event-driven (within seconds)

Master: app_master_db.regions

@Entity({ name: 'regions', database: AppDatabases.APP_MASTER })
export class Region {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, unique: true })
code: string; // e.g., "US-CA" for California
@Column({ type: 'varchar', length: 100 })
name: string; // "California"
@Column({ type: 'varchar', length: 10 })
zone: string; // "West", "East", "Central", etc.
// ... audit fields
}

Lookup: app_core_db.regions_lookup

@Entity({ name: 'regions_lookup', database: AppDatabases.APP_CORE })
export class RegionLookup {
@PrimaryColumn({ type: 'varchar', length: 10 })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 10 })
zone: string;
@UpdateDateColumn({ type: 'timestamptz' })
synced_at: Date;
@Column({ type: 'uuid', nullable: true })
master_id: string | null;
}

Usage:

-- Resource addresses with region names
SELECT
r.reference_number,
r.first_name,
a.address_line1,
rg.name AS region_name
FROM resources r
LEFT JOIN resource_addresses a ON r.id = a.resource_id
LEFT JOIN regions_lookup rg ON a.region_code = rg.code;

Master: app_master_db.classification_codes

@Entity({ name: 'classification_codes', database: AppDatabases.APP_MASTER })
export class ClassificationCode {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, unique: true })
code: string; // e.g., "E11.9"
@Column({ type: 'text' })
description: string; // "Type 2 diabetes mellitus without complications"
@Column({ type: 'varchar', length: 50 })
category: string; // "Endocrine, nutritional and metabolic diseases"
// ... audit fields
}

Lookup: app_core_db.classification_codes_lookup

@Entity({ name: 'classification_codes_lookup', database: AppDatabases.APP_CORE })
export class ClassificationCodeLookup {
@PrimaryColumn({ type: 'varchar', length: 10 })
code: string;
@Column({ type: 'text' })
description: string;
@Column({ type: 'varchar', length: 50 })
category: string;
@UpdateDateColumn({ type: 'timestamptz' })
synced_at: Date;
@Column({ type: 'uuid', nullable: true })
master_id: string | null;
}

Usage:

-- Entries with classification code descriptions
SELECT
e.engagement_id,
e.entry_date,
c.code AS classification_code,
c.description
FROM entries e
LEFT JOIN classification_codes_lookup c ON e.classification_code = c.code
WHERE e.resource_id = 'resource-uuid-123';

sequenceDiagram
    participant Admin as Admin User
    participant MasterService as MasterData Service
    participant EventBus as Event Bus
    participant CoreService as Data Owner BC
    participant CoreDB as app_core_db

    Admin->>MasterService: POST /api/v1/labels (Create new label)
    MasterService->>MasterService: Validate DTO
    MasterService->>MasterService: INSERT INTO labels
    MasterService->>EventBus: Emit 'label.created' event
    MasterService-->>Admin: 201 Created

    EventBus->>CoreService: Deliver 'label.created' event
    CoreService->>CoreService: Handle event
    CoreService->>CoreDB: UPSERT INTO labels_lookup
    CoreDB-->>CoreService: Success
    CoreService->>EventBus: ACK event

Event Structure:

interface MasterDataEvent {
event: 'label.created' | 'label.updated' | 'label.deleted';
payload: {
code: string;
label: string;
display_name: string;
abbreviation: string | null;
display_order: number;
is_active: boolean;
};
metadata: {
timestamp: string;
source: 'masterdata-service';
userId: string;
};
}

Purpose: Ensures eventual consistency even if events are missed

apps/data-owner-bc/src/modules/lookup/services/label-sync.service.ts
@Injectable()
export class LabelSyncService {
/**
* Daily full sync at 2 AM
* Ensures no data drift between master and lookup tables
*/
@Cron(CronExpression.EVERY_DAY_AT_2AM, {
name: 'full-sync-labels',
timeZone: 'UTC',
})
async fullSyncLabels() {
this.logger.log('Starting full sync of labels from master...');
// Fetch all active labels from master via microservice call
const allLabels = await this.microserviceClient.sendWithContext<Label[]>(
this.logger,
this.masterDataClient,
{ cmd: AppMicroservice.MasterData.cmd.GetAllActiveLabels },
{},
[],
);
// Upsert all labels into lookup table
await this.labelLookupRepository.upsert(
allLabels.map((label) => ({
code: label.code,
label: label.label,
display_name: label.display_name,
abbreviation: label.abbreviation,
display_order: label.display_order,
is_active: label.is_active,
master_id: label.id,
})),
['code'], // Conflict target
);
this.logger.log(`Full sync completed: ${allLabels.length} labels synced`);
}
}

import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLabelsTable1733000000000 implements MigrationInterface {
name = 'CreateLabelsTable1733000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "labels" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
"code" varchar(10) NOT NULL UNIQUE,
"label" varchar(50) NOT NULL,
"display_name" varchar(50) NOT NULL,
"abbreviation" varchar(50),
"display_order" int DEFAULT 0,
"is_active" boolean DEFAULT true,
"created_at" timestamptz DEFAULT now(),
"updated_at" timestamptz DEFAULT now(),
"created_by" uuid,
"updated_by" uuid,
"deleted_at" timestamptz
);
COMMENT ON TABLE "labels" IS 'Master data: Label codes (Mr., Mrs., Dr., etc.)';
COMMENT ON COLUMN "labels"."code" IS 'Unique label code (e.g., MR, MRS, DR)';
CREATE INDEX "idx_labels_code" ON "labels"("code");
CREATE INDEX "idx_labels_is_active" ON "labels"("is_active");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "labels"`);
}
}
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLabelsLookupTable1733000100000 implements MigrationInterface {
name = 'CreateLabelsLookupTable1733000100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "labels_lookup" (
"code" varchar(10) PRIMARY KEY,
"label" varchar(50) NOT NULL,
"display_name" varchar(50) NOT NULL,
"abbreviation" varchar(50),
"display_order" int DEFAULT 0,
"is_active" boolean DEFAULT true,
"synced_at" timestamptz DEFAULT now(),
"master_id" uuid
);
COMMENT ON TABLE "labels_lookup" IS
'Read model replicated from app_master_db.labels for reporting tools';
CREATE INDEX "idx_labels_lookup_code" ON "labels_lookup"("code");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "labels_lookup"`);
}
}