How I map DDD aggregate roots to single tables, embed value objects as columns, run optimistic concurrency on a version column, and evolve schema without locking hot tables.
It was a Thursday afternoon at the digital product agency I led engineering at, and I was staring at an orders table with 47 columns trying to figure out where the Address value object had gone. The domain model had a clean ShippingAddress value object. The DB had ship_addr1, ship_addr2, ship_city_or_region, ship_country_code, a half-empty ship_postal_zip field, and a notes column that someone had been using as a freeform second address line for three years.
This was during a portfolio-wide migration to DDD that we’d kicked off the previous quarter, and the question staring back at me wasn’t theoretical. It was: do I split this thing into its own table to be “pure”, or do I leave it embedded and move on with my life. I left it embedded. I’d make the same call today.
DDD gives you the building blocks: entities, value objects, aggregate roots. The relational DB does not care about any of that. What it cares about is rows, joins, indexes, locks, and how badly a JOIN performs under load. So the mapping between the two is where most teams either over-engineer or under-engineer, and you usually find out which one you did about eighteen months in.
My take: map one aggregate root to one table. Fold value objects into columns on that same table. Give child entities their own tables only when they’re actually distinct rows you’d want to query independently, and even then keep their lifecycle locked to the aggregate. Don’t normalize value objects into separate tables. Don’t try to mirror the OO graph in the schema. The aggregate is your consistency boundary; the row is your concurrency boundary. Keep them aligned.
The alternative I’m rejecting is the table-per-class-hierarchy DDD-purity track where every value object becomes a join. I’ve seen that pattern up close, and the cost shows up at read time, not at design time.
Here’s a typical Order aggregate, written the way I’d actually ship it.
// src/domain/order/Order.ts
import { Money } from './Money'
import { Address } from './Address'
import { OrderLine } from './OrderLine'
import { DomainError } from '../shared/DomainError'
export class Order {
private constructor(
public readonly id: string,
public readonly customerId: string,
private status: 'pending' | 'paid' | 'shipped' | 'cancelled',
private readonly lines: OrderLine[],
public readonly shippingAddress: Address,
public readonly total: Money,
public version: number,
) {}
static create(input: {
id: string
customerId: string
lines: OrderLine[]
shippingAddress: Address
}): Order {
if (input.lines.length === 0) {
throw new DomainError('Order must have at least one line')
}
const total = input.lines.reduce(
(acc, line) => acc.add(line.subtotal()),
Money.zero('USD'),
)
return new Order(input.id, input.customerId, 'pending', input.lines, input.shippingAddress, total, 0)
}
markPaid(): void {
if (this.status !== 'pending') {
throw new DomainError(`Cannot pay an order in state ${this.status}`)
}
this.status = 'paid'
}
}
shippingAddress is a value object. total is a value object. Both live inside the aggregate. Neither needs its own table.
The persistence model mirrors that. With TypeORM, you use embedded columns; with Prisma, you flatten the fields; with ActiveRecord, you use composed_of.
// src/infra/order/OrderEntity.ts
import { Column, Entity, OneToMany, PrimaryColumn, VersionColumn } from 'typeorm'
import { AddressEntity } from './AddressEntity'
import { MoneyEntity } from './MoneyEntity'
import { OrderLineEntity } from './OrderLineEntity'
@Entity({ name: 'orders' })
export class OrderEntity {
@PrimaryColumn('uuid')
id!: string
@Column('uuid', { name: 'customer_id' })
customerId!: string
@Column({ type: 'varchar', length: 16 })
status!: 'pending' | 'paid' | 'shipped' | 'cancelled'
@Column(() => AddressEntity, { prefix: 'shipping_' })
shippingAddress!: AddressEntity
@Column(() => MoneyEntity, { prefix: 'total_' })
total!: MoneyEntity
@OneToMany(() => OrderLineEntity, (line) => line.order, { cascade: true, eager: true })
lines!: OrderLineEntity[]
@VersionColumn({ name: 'lock_version' })
version!: number
}
That produces an orders table with shipping_street, shipping_city, shipping_country, total_amount, total_currency, plus the usual aggregate columns. No addresses table. No monies table. Address has no identity outside of the order it ships to, so it has no business owning a row.
The Prisma flavor of the same thing:
// prisma/schema.prisma
model Order {
id String @id @db.Uuid
customerId String @map("customer_id") @db.Uuid
status String @db.VarChar(16)
shippingStreet String @map("shipping_street")
shippingCity String @map("shipping_city")
shippingCountry String @map("shipping_country") @db.Char(2)
totalAmount Decimal @map("total_amount") @db.Decimal(12, 2)
totalCurrency String @map("total_currency") @db.Char(3)
lockVersion Int @default(0) @map("lock_version")
lines OrderLine[]
@@map("orders")
}
model OrderLine {
id String @id @db.Uuid
orderId String @map("order_id") @db.Uuid
productId String @map("product_id") @db.Uuid
quantity Int
unitAmount Decimal @map("unit_amount") @db.Decimal(12, 2)
unitCurrency String @map("unit_currency") @db.Char(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("order_lines")
}
The ActiveRecord version, for the Rails crowd:
# app/models/order.rb
class Order < ApplicationRecord
composed_of :shipping_address,
class_name: 'Address',
mapping: [
%w[shipping_street street],
%w[shipping_city city],
%w[shipping_country country],
]
composed_of :total,
class_name: 'Money',
mapping: [%w[total_amount amount], %w[total_currency currency]]
has_many :order_lines, dependent: :destroy
end
Same idea, three ORMs. One row per order. Value objects fold in.
The lock_version column isn’t ceremony. It’s how you keep two requests from quietly overwriting each other’s view of the same aggregate. Pessimistic locks are fine in theory, but at any real concurrency you don’t want to hold row locks across a domain operation that might call out to a payment provider.
TypeORM gives you @VersionColumn. Prisma doesn’t, so you do it yourself with updateMany:
// src/infra/order/OrderRepository.ts
async function save(order: Order): Promise<void> {
const result = await prisma.order.updateMany({
where: { id: order.id, lockVersion: order.version },
data: {
status: order.status,
totalAmount: order.total.amount,
totalCurrency: order.total.currency,
lockVersion: order.version + 1,
},
})
if (result.count === 0) {
throw new ConcurrencyError(`Order ${order.id} was modified by another transaction`)
}
order.version += 1
}
The updateMany trick is critical. A normal update doesn’t let you condition on the current version. updateMany does. If count === 0, someone else won.
OK so this is the part where I learned a lesson the hard way. Late-evening deploy at the creator economy platform I worked at last year. We were adding a non-null boolean column to a hot table with hundreds of millions of rows. The migration used the “safe” gem helper with add_column ... null: false, default: false. I’d reviewed it that morning and acked it as safe.
It wasn’t. The migration grabbed an ACCESS EXCLUSIVE lock on the table while backfilling the default. On Aurora at that row count, that’s about 90 seconds of blocked writes, and “writes” meant basically every customer-facing path that touched a user row, plus the downstream webhooks fan-out. Login error rate climbed to 100% for around 85 seconds. The pager started lighting up across the West Coast on-call rotation. Our first instinct was to roll back, but the migration was past the point where a clean rollback would help. We let it finish. 87 seconds. Locks released. Login recovered within fifteen seconds because the dependent service had a tight retry loop.
The postmortem was straightforward. The “safe” gem helper is safer than raw ActiveRecord. It is not safe. Against a hot table on Aurora, every schema change is a three-step dance:
# db/migrate/20240501_add_loyalty_tier_to_orders_step1.rb
class AddLoyaltyTierToOrdersStep1 < ActiveRecord::Migration[7.1]
def change
add_column :orders, :loyalty_tier, :string, null: true, default: nil
end
end
# db/migrate/20240502_backfill_loyalty_tier.rb
class BackfillLoyaltyTier < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def up
Order.in_batches(of: 5_000) do |relation|
relation.where(loyalty_tier: nil).update_all(loyalty_tier: 'standard')
sleep 0.1
end
end
def down; end
end
# db/migrate/20240503_enforce_loyalty_tier_not_null.rb
class EnforceLoyaltyTierNotNull < ActiveRecord::Migration[7.1]
def change
change_column_null :orders, :loyalty_tier, false
end
end
Three migrations. One column. That’s the price of evolving an aggregate’s schema while millions of customers are still logging in. We also added a CI rule that blocks any add_column with a non-null default against tables tagged as hot. The gem doesn’t know what’s hot. You do.
Child entities like OrderLine get their own table because they’re rows you might want to count or join against analytics. But they don’t have an independent life. Repository never returns an OrderLine directly. It returns an Order with its lines loaded. Foreign key with ON DELETE CASCADE. No back-channel into the child from outside the aggregate.
async function findById(id: string): Promise<Order | null> {
const row = await this.repo.findOne({ where: { id }, relations: { lines: true } })
return row ? this.toDomain(row) : null
}
One query, full aggregate. If you find yourself building a findOrderLineById somewhere, that’s a smell. The line doesn’t have meaning outside its order. Honor the boundary.
composed_of, TypeORM’s embedded columns, and Prisma’s flat field layout all support the same DDD shape.Thanks for reading. If you’ve got thoughts, send them my way.