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

AspectSQL TablesMongoDB Document
Tables/Documents7+ tables1 document
Read Query6 JOINsfindOne()
Write AtomicityMulti-statement transactionSingle update
Consistency BoundaryApplication logicDocument boundary
Migration ComplexityHigh (ALTER TABLE)Low (flexible schema)
Disk I/OScattered across tablesContiguous 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'})

Run the full demo →

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

Next in Series

Coming next: "From JPA Entities to Spring Data MongoDB — Same Domain, Different Persistence"

We'll build a complete order management system in both JPA/Hibernate and Spring Data MongoDB, comparing lines of code, migration complexity, and query performance.