Your DDD Aggregates Are Already Documents
Stop Normalizing Aggregates Into 7 Tables When One Document is Correct
📋 Contents
The Problem: Over-Normalization
Relational databases train us to normalize everything. Third normal form becomes dogma. But when modeling Domain-Driven Design aggregates, this creates friction:
- 7+ tables for one business concept
- Complex joins to reconstitute aggregates
- Distributed transactions across tables
- Atomicity challenges for aggregate updates
DDD Aggregates: A Quick Recap
According to Eric Evans' Domain-Driven Design:
"An Aggregate is a cluster of associated objects that we treat as a unit for the purpose of data changes."
Key characteristics:
- Consistency boundary
- Single aggregate root
- Transactional scope
- Invariant enforcement
SQL Relational Implementation
Take an insurance claim processing domain:
-- Tables needed for one Claim aggregate
CREATE TABLE claims (
id UUID PRIMARY KEY,
policy_number VARCHAR(50),
claim_status VARCHAR(20),
created_at TIMESTAMP
);
CREATE TABLE claim_lines (
id UUID PRIMARY KEY,
claim_id UUID REFERENCES claims(id),
line_item VARCHAR(100),
amount DECIMAL(10,2)
);
CREATE TABLE claim_documents (
id UUID PRIMARY KEY,
claim_id UUID REFERENCES claims(id),
document_type VARCHAR(50),
storage_url TEXT
);
-- Plus: adjusters, payments, notes... 7+ tables total
Issues:
- Foreign key constraints across consistency boundary
- Need for transaction spanning multiple tables
- JOIN queries to read full aggregate
- No database-level aggregate boundary enforcement
MongoDB Document Implementation
Same domain, document approach:
// Single document = single aggregate
{
"_id": "claim_12345",
"type": "insurance_claim",
"policy_number": "POL-2023-001",
"status": "under_review",
"created_at": ISODate("2026-04-17T20:50:00Z"),
// Embedded line items
"line_items": [
{
"description": "Windshield replacement",
"amount": 1200.00,
"approved": true
}
],
// Embedded documents
"documents": [
{
"type": "estimate",
"url": "s3://bucket/estimate.pdf",
"uploaded_at": ISODate("2026-04-16T10:30:00Z")
}
],
// Embedded adjuster assignment
"assigned_adjuster": {
"adjuster_id": "adj_456",
"assigned_at": ISODate("2026-04-17T09:00:00Z")
}
}
Advantages:
- Single read/write for full aggregate
- Atomic updates with document-level transactions
- No JOINs needed
- Natural consistency boundary
Side-by-Side Comparison
| Aspect | SQL Tables | MongoDB Document |
|---|---|---|
| Tables/Documents | 7+ tables | 1 document |
| Read Query | 6 JOINs | findOne() |
| Write Atomicity | Multi-statement transaction | Single update |
| Consistency Boundary | Application logic | Document boundary |
| Migration Complexity | High (ALTER TABLE) | Low (flexible schema) |
| Disk I/O | Scattered across tables | Contiguous storage |
Live Demo
Try it yourself:
# PostgreSQL setup
docker run -d --name postgres-claim -e POSTGRES_PASSWORD=secret postgres:15
# MongoDB setup
docker run -d --name mongo-claim mongo:7
# Compare query complexity:
# PostgreSQL: 6-table JOIN
# MongoDB: db.claims.findOne({_id: 'claim_12345'})
When to Use Each Approach
✅ Use SQL Tables When:
- Aggregate pieces need independent querying
- You have complex cross-aggregate reports
- Data normalizes naturally (1:many, many:many)
- Team has strong SQL skills
✅ Use MongoDB Documents When:
- Aggregate reads/writes as a unit
- Consistency boundary = document boundary
- Schema evolution expected
- Development velocity critical