Three ways to persist a DDD aggregate (relational ORM, document store, event sourcing) behind the same repository interface, and the tradeoffs that actually showed up in production.
The fight wasn’t about DDD. It was about a Wednesday afternoon at the London product agency I led at, with three of us standing around a whiteboard, trying to figure out why the Order aggregate kept growing a new column every sprint. Someone said, “let’s just put it in Mongo and be done with it.” Someone else said, “we’re going to regret that in six months.” I said, “depends what we mean by persistence.” Yeah. That sentence cost us about two hours.
So this is about the boring middle of DDD. You’ve got your aggregate. You’ve drawn the consistency boundary, you’ve made Order the root, you’ve decided line items don’t get to exist without a parent. Now the question is, where does this thing actually live. And I want to walk through the three options I’ve shipped to production, behind the same repository interface, with the same domain model in front. Because the repository is the seam, and the seam is the whole point.
The domain doesn’t know how Order is stored. That’s not a slogan, that’s a literal code contract. The interface lives in the domain layer. The implementations live in infra. You’d be surprised how often I’ve seen teams violate this by leaking ActiveRecord scopes into domain services and then wondering why DDD “didn’t work for them”.
// domain/orders/order.repository.ts
import { Order } from "./order";
import { OrderId } from "./order-id";
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
// intentionally narrow. no findAll, no query builder. domain doesn't browse.
}
That’s it. The aggregate goes in, the aggregate comes out. The thing inside save can be a relational mapping, a document, or a stream of events. The domain doesn’t care.
This is what most teams reach for and honestly it’s the right default. Your aggregate maps to a small graph of tables. The root has an id, the entities inside the boundary get their own table with a FK back to the root, value objects collapse into columns on whatever entity owns them. Transactions give you the consistency you need at the aggregate boundary for free.
I’ve shipped this with both TypeORM and Prisma. Prisma is fine for simple aggregates, TypeORM I’d reach for when the mapping gets weird (custom value-object transformers, embedded tables, lifecycle hooks I actually want to control).
// infra/orders/order.repository.typeorm.ts
import { DataSource } from "typeorm";
import { Order } from "../../domain/orders/order";
import { OrderRepository } from "../../domain/orders/order.repository";
import { OrderRow, OrderLineRow } from "./order.entity";
import { toDomain, toRows } from "./order.mapper";
export class TypeOrmOrderRepository implements OrderRepository {
constructor(private readonly ds: DataSource) {}
async findById(id) {
const row = await this.ds.getRepository(OrderRow).findOne({
where: { id: id.value },
relations: { lines: true },
});
return row ? toDomain(row) : null;
}
async save(order: Order) {
const { root, lines } = toRows(order);
await this.ds.transaction(async (tx) => {
await tx.getRepository(OrderRow).save(root);
await tx.getRepository(OrderLineRow).delete({ orderId: root.id });
await tx.getRepository(OrderLineRow).save(lines);
});
}
}
The mapper is where you earn your money. Domain in, rows out. Rows in, domain out. Keep it stupid. Never let the entity classes leak into your service code.
Tradeoffs are well known. Reads are easy, writes are transactional, schemas are explicit. The pain shows up when the aggregate evolves quickly and every change is a migration. On the multi-terabyte Aurora writer at the creator economy platform I worked at, a careless add_column null: false default: false against a hot table cost us about 85 seconds of total login outage one evening. I’d reviewed that migration. strong_migrations was on. Didn’t matter. On Aurora at that row count, every schema change against a hot table is a three-step dance, and the gem’s defaults are safer than raw ActiveRecord, not safe. I’m the reason a CI rule now blocks any add_column with a non-null default against tables over ten million rows.
When the aggregate fits in your head and never gets queried outside its boundary, a document store is genuinely the right call. The whole aggregate serializes to one document. One write, one read, no joins. You give up cross-aggregate consistency and ad-hoc reporting. You buy speed and a flat object model.
I’ve done this with MongoDB and Firestore. Firestore at the live-video creator platform I led engineering at, mostly for short-lived session-shaped aggregates where the read pattern was always “give me the whole thing by id”.
// infra/orders/order.repository.mongo.ts
import { Collection, MongoClient } from "mongodb";
import { OrderRepository } from "../../domain/orders/order.repository";
import { Order } from "../../domain/orders/order";
import { toDocument, fromDocument, OrderDoc } from "./order.doc";
export class MongoOrderRepository implements OrderRepository {
private readonly col: Collection<OrderDoc>;
constructor(client: MongoClient) {
this.col = client.db("commerce").collection<OrderDoc>("orders");
}
async findById(id) {
const doc = await this.col.findOne({ _id: id.value });
return doc ? fromDocument(doc) : null;
}
async save(order: Order) {
const doc = toDocument(order);
await this.col.replaceOne(
{ _id: doc._id, version: doc.version - 1 },
doc,
{ upsert: true },
);
// optimistic concurrency. if the version doesn't match, you didn't write.
}
}
That version check is not optional. Without it you have a last-writer-wins aggregate and you will lose data, and you will only find out when a customer screenshots two conflicting receipts. Ask me about the native subscription mess at the creator platform, where the renewal handler had no idempotency key and a slow 200 OK triggered Apple to retry the server-to-server notification. Every retry created a new subscription row. A few thousand customers got billed twice. The frontend fix went out within an hour and didn’t help anyone. The real fix was a unique constraint on (apple_original_transaction_id, notification_uuid) and a Sidekiq job that returned 200 OK in under five seconds. Same lesson applies to document stores. If you don’t enforce write ordering, the store will silently lose information.
The third option is to not store the aggregate at all. Store the events. The aggregate is what you get when you fold the events. Saves are appends to a stream, reads are replays.
// infra/orders/order.repository.es.ts
import { OrderRepository } from "../../domain/orders/order.repository";
import { Order } from "../../domain/orders/order";
import { EventStore } from "../event-store";
export class EventSourcedOrderRepository implements OrderRepository {
constructor(private readonly store: EventStore) {}
async findById(id) {
const events = await this.store.readStream(`order-${id.value}`);
if (events.length === 0) return null;
return Order.replay(events);
}
async save(order: Order) {
const newEvents = order.pullDomainEvents();
if (newEvents.length === 0) return;
await this.store.append(
`order-${order.id.value}`,
newEvents,
order.expectedVersion, // optimistic concurrency at the stream level
);
}
}
This is powerful and I genuinely like it for audit-heavy domains. Financial flows, ledgers, anything regulatory. You get the full history for free. You get to project the same stream into many read models. You get time travel.
You also get a lot of operational weight. Read models drift. Indexers go silent. Freshness is its own metric. Derived indexes need their own health signal, not just “is the consumer still consuming”. Event sourcing makes you live with that lesson every day.
Look, in practice I default to relational. It’s the boring choice and boring is what you want for the boundary that survives the longest. Document store when the aggregate is genuinely document-shaped and you don’t have cross-aggregate queries. Event sourcing when the history is the product, not a side effect.
The shape of the code doesn’t change. Only the implementation behind the interface does. That’s the win.
Thanks for reading. If you’ve got thoughts, send them my way.