Domain‑Driven Design, ORMs, and Developer Experience with PostgreSQL

From Shared Normalized Schemas to Bounded Contexts with JSONB Aggregates

🏛️ Then: Database‑Centric Architecture (2000s)

  • Central database shared by all applications
  • Normalized schemas, strong integrity constraints
  • Business logic in stored procedures
  • Views for application‑specific access

🚀 Now: Application‑Centric Architecture (2020s)

  • Database‑per‑service (bounded context)
  • Business logic in domain objects
  • ORMs for object‑relational mapping
  • JSONB for document‑style aggregates

🏛️ Era 1: The Database as Central Hub

Characteristics:

    [Application A]      [Application B]      [Application C]
           ↓                    ↓                    ↓
      ┌────────────────────────────────────────────────────┐
      │               CENTRAL POSTGRESQL                   │
      │  • Normalized schemas (3NF)                       │
      │  • Referential integrity constraints              │
      │  • Stored procedures for business logic           │
      │  • Views for application‑specific access          │
      └────────────────────────────────────────────────────┘
                

Strengths:

  • Single source of truth – Consistent data across applications
  • Data integrity – Constraints enforced at database level
  • Shared business logic – Stored procedures reusable by all apps
  • Cross‑application reporting – Direct SQL joins across domains

Pain Points:

  • Monolithic schema changes – Affects all applications
  • DBA bottleneck – Every change requires DBA approval
  • Application‑database impedance – Object‑relational mismatch
  • Tight coupling – Applications depend on shared schema

🧠 Paradigm Shift: Domain‑Driven Design

Introducing Bounded Contexts – each with:

Bounded ContextOwned DatabaseAggregate ExampleTechnology
Order Management PostgreSQL cluster A Order, OrderLines, CustomerInfo Spring Boot + Hibernate
Inventory PostgreSQL cluster B Product, StockLevel, Reservations TypeORM + Node.js
Payment Processing PostgreSQL cluster C Payment, Invoice, Transaction Entity Framework + .NET
Customer Support MongoDB cluster D Ticket, Messages, Attachments Spring Data MongoDB

Key DDD Concepts for Database Professionals:

📦 Aggregate

Cluster of related objects treated as a unit for data changes.

// Order aggregate:
{
  order_id: 123,
  customer_id: 456,
  line_items: [...],
  shipping_address: {...},
  payment_status: "paid"
  // All updated atomically
}

🏗️ Aggregate Root

Single entry point to the aggregate that enforces invariants.

class Order {
  private List items;
  
  public void addItem(Product p, int qty) {
    // Business logic here
    items.add(new OrderItem(p, qty));
    this.calculateTotal();
  }
}

🔗 Consistency Boundary

Limit of transactional consistency. Often = aggregate boundary.

// PostgreSQL with JSONB:
CREATE TABLE orders (
  id UUID PRIMARY KEY,
  aggregate JSONB NOT NULL,
  version INT DEFAULT 1
);

-- Atomic update of entire aggregate
UPDATE orders 
SET aggregate = '{"items":[...], "total":...}'
WHERE id = ? AND version = ?;

⚡ How PostgreSQL Adapted: ORMs + JSONB

1. The ORM Revolution

✅ Without ORM (Direct SQL)

// Java + JDBC boilerplate
PreparedStatement stmt = conn.prepareStatement(
    "INSERT INTO orders (id, customer_id, total) VALUES (?, ?, ?)"
);
stmt.setObject(1, order.getId());
stmt.setObject(2, order.getCustomerId());
stmt.setBigDecimal(3, order.getTotal());
stmt.executeUpdate();

// Plus: line_items, addresses tables...
// 50+ lines for one aggregate

✅ With ORM (Hibernate)

// Java + Hibernate
@Entity
@Table(name = "orders")
class Order {
    @Id UUID id;
    @OneToMany List items;
    @Embedded Address shippingAddress;
    
    // Business logic methods...
}

// Save entire aggregate:
entityManager.persist(order);
// 5 lines, includes all related objects

2. PostgreSQL JSONB: Document‑Style Aggregates

🌐 JSONB Column Type

CREATE TABLE order_aggregates (
    id UUID PRIMARY KEY,
    version INTEGER DEFAULT 1,
    aggregate JSONB NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    -- Index for common queries
    CONSTRAINT valid_order 
      CHECK (aggregate ? 'customer_id')
);

🔍 Powerful Querying

-- Find high-value orders
SELECT * FROM order_aggregates
WHERE (aggregate->>'total')::numeric > 1000;

-- Update specific field
UPDATE order_aggregates
SET aggregate = jsonb_set(
    aggregate,
    '{status}',
    '"shipped"'
)
WHERE id = ?;

🎯 Index Support

-- GIN index for flexible queries
CREATE INDEX idx_gin_aggregate 
ON order_aggregates USING GIN (aggregate);

-- Expression index for performance
CREATE INDEX idx_total 
ON order_aggregates 
((aggregate->>'total')::numeric);

🛠️ Modern Developer Experience

Development Flow with DDD + PostgreSQL:

  1. Domain Modeling

    Identify aggregates, bounded contexts with domain experts.

  2. Database‑per‑Service

    Each bounded context gets its own PostgreSQL database.

  3. Schema‑as‑Code

    Database schema defined in migration files (Liquibase/Flyway).

  4. Test‑Driven Development

    Aggregate tests with in‑memory H2 → PostgreSQL integration tests.

  5. Containerized Development

    docker‑compose with PostgreSQL + application containers.

Communication Bridge: DBAs ↔ Developers

DBA ConcernDeveloper PerspectiveModern Solution
Data Integrity Aggregate invariants in code JSONB schema validation + unit tests
Performance Developer productivity ORM optimizations + query analysis
Backup/Recovery Zero‑downtime deployments Blue‑green deploys + PITR
Security Rapid iteration Row‑level security + connection pooling

🎯 Hands‑On Demos

Demo 1: Traditional vs DDD‑Style Schemas

-- Traditional (7 tables)
CREATE TABLE orders (...);
CREATE TABLE order_items (...);
CREATE TABLE customers (...);
CREATE TABLE addresses (...);
CREATE TABLE payments (...);
CREATE TABLE shipments (...);
CREATE TABLE order_history (...);

-- DDD‑Style (2 tables)
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    aggregate JSONB,  -- items, addresses embedded
    version INT
);
CREATE TABLE customers (
    id UUID PRIMARY KEY,
    details JSONB     -- contact info, preferences
);

Demo 2: Migration with Flyway

-- V1__initial_schema.sql
CREATE TABLE order_aggregates (...);

-- V2__add_versioning.sql
ALTER TABLE order_aggregates 
ADD COLUMN version INTEGER DEFAULT 1;

-- V3__add_status_index.sql
CREATE INDEX idx_aggregate_status 
ON order_aggregates ((aggregate->>'status'));

Demo 3: Aggregate Repository Pattern

@Repository
public class OrderAggregateRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public Order findById(UUID id) {
        // Returns Order with all line items loaded
        return entityManager.find(Order.class, id);
    }
    
    public void save(Order order) {
        // Saves entire aggregate graph
        entityManager.persist(order);
    }
}

🎓 Key Takeaways

For DBAs

  • Understand bounded contexts → separate databases
  • JSONB is strategic, not tactical
  • Focus on platform‑level concerns: HA, backup, monitoring
  • Enable developer self‑service with good tooling

For Developers

  • PostgreSQL with JSONB ≈ document database + SQL
  • ORM‑generated SQL needs monitoring
  • Database constraints still valuable for core invariants
  • Version all schema changes (Flyway/Liquibase)

For Architects

  • Choose database per bounded context (Polyglot persistence)
  • Eventual consistency between contexts (not immediate)
  • API boundaries ≠ database boundaries
  • Invest in developer experience tooling

📚 Further Reading