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 Context | Owned Database | Aggregate Example | Technology |
|---|---|---|---|
| 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:
-
Domain Modeling
Identify aggregates, bounded contexts with domain experts.
-
Database‑per‑Service
Each bounded context gets its own PostgreSQL database.
-
Schema‑as‑Code
Database schema defined in migration files (Liquibase/Flyway).
-
Test‑Driven Development
Aggregate tests with in‑memory H2 → PostgreSQL integration tests.
-
Containerized Development
docker‑compose with PostgreSQL + application containers.
Communication Bridge: DBAs ↔ Developers
| DBA Concern | Developer Perspective | Modern 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