Loading content...
The repository interface presents a clean, domain-aligned abstraction. But behind that abstraction lies real complexity: SQL queries, ORM configurations, connection pooling, transaction management, and the perennial challenge of mapping between object and relational worlds.
Repository implementation is where DDD ideals meet infrastructure realities. A well-implemented repository faithfully fulfills the interface contract while efficiently leveraging the underlying data store. A poorly implemented one introduces subtle bugs, performance problems, and maintenance nightmares.
This page addresses the practical challenges every developer faces when implementing repositories in real systems.
This page covers ORM integration strategies, domain-to-persistence mapping techniques, transaction and Unit of Work coordination, performance optimization patterns, and testing approaches for repository implementations.
Most repository implementations use an Object-Relational Mapper (ORM) rather than raw SQL. ORMs like Prisma, TypeORM, Hibernate, Entity Framework, and SQLAlchemy provide query builders, change tracking, and relationship management.
The key challenge: ORMs have their own object models ("entities" in ORM parlance) that often don't align perfectly with DDD aggregates. You must decide how to bridge this gap.
Strategy 1: Separate Domain and Persistence Models
The purest approach maintains complete separation between domain aggregates and ORM entities. The repository maps between them.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Domain model - pure, no ORM dependencies// domain/model/Order.tsclass Order { private constructor( private readonly _id: OrderId, private readonly _customerId: CustomerId, private _items: OrderItem[], private _status: OrderStatus, private _shippingAddress: ShippingAddress ) {} static create(customerId: CustomerId, items: OrderItem[]): Order { // Domain logic, invariant validation } addItem(item: OrderItem): void { // Domain logic } cancel(): void { if (!this._status.canCancel()) { throw new OrderCannotBeCancelledException(this._id); } this._status = OrderStatus.CANCELLED; }} // ORM/Persistence model - Prisma-generated// Matches database schema, no domain logicinterface PrismaOrder { id: string; customerId: string; status: string; shippingAddressLine1: string; shippingAddressLine2: string | null; shippingCity: string; shippingPostalCode: string; createdAt: Date; updatedAt: Date; items: PrismaOrderItem[];} // Repository implementation bridges the gapclass OrderRepositoryImpl implements OrderRepository { constructor( private prisma: PrismaClient, private mapper: OrderMapper ) {} async findById(orderId: OrderId): Promise<Order | null> { const data = await this.prisma.order.findUnique({ where: { id: orderId.value }, include: { items: true } }); if (!data) return null; // Map from ORM entity to domain aggregate return this.mapper.toDomain(data); } async add(order: Order): Promise<void> { // Map from domain aggregate to ORM entity const data = this.mapper.toPersistence(order); await this.prisma.order.create({ data: { ...data, items: { create: data.items } } }); }}Strategy 2: Single Model with ORM Decorators
A more pragmatic approach uses domain classes directly as ORM entities, accepting some coupling.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Combined domain + persistence model@Entity('orders')class Order { @PrimaryColumn() private _id: string; @Column() private _customerId: string; @Column({ type: 'enum', enum: OrderStatus }) private _status: OrderStatus; @OneToMany(() => OrderItem, item => item.order, { cascade: true }) private _items: OrderItem[]; @Embedded(() => ShippingAddress) private _shippingAddress: ShippingAddress; // Domain behavior still present addItem(item: OrderItem): void { // Domain logic } cancel(): void { if (!this._status.canCancel()) { throw new OrderCannotBeCancelledException(this._id); } this._status = OrderStatus.CANCELLED; } // Factory method for creation static create(customerId: CustomerId, items: OrderItem[]): Order { // Validation and initialization }} // Repository is simpler - no mapping neededclass OrderRepositoryImpl implements OrderRepository { constructor(private dataSource: DataSource) {} async findById(orderId: OrderId): Promise<Order | null> { return this.dataSource.getRepository(Order) .findOne({ where: { id: orderId.value }, relations: ['items'] }); } async add(order: Order): Promise<void> { await this.dataSource.getRepository(Order).save(order); }}For simpler domains, single-model approaches reduce boilerplate significantly. For complex domains where database schema differs substantially from the domain model, separate models are worth the investment. Choose based on your domain's complexity and team's capacity.
When using separate domain and persistence models, mappers translate between them. This translation is non-trivial for complex aggregates with nested entities and value objects.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// Mapper for Order aggregateclass OrderMapper { constructor( private orderItemMapper: OrderItemMapper, private shippingAddressMapper: ShippingAddressMapper ) {} /** * Maps from persistence model to domain aggregate. * Called when loading from database. */ toDomain(data: PrismaOrder & { items: PrismaOrderItem[] }): Order { // Reconstitute value objects const orderId = OrderId.create(data.id); const customerId = CustomerId.create(data.customerId); const status = OrderStatus.fromString(data.status); const shippingAddress = this.shippingAddressMapper.toDomain({ line1: data.shippingAddressLine1, line2: data.shippingAddressLine2, city: data.shippingCity, postalCode: data.shippingPostalCode, }); // Reconstitute child entities const items = data.items.map(i => this.orderItemMapper.toDomain(i)); // Use factory or reconstitution method on aggregate // Note: reconstitute() differs from create() - no validation for new orders return Order.reconstitute({ id: orderId, customerId, items, status, shippingAddress, }); } /** * Maps from domain aggregate to persistence model. * Called when saving to database. */ toPersistence(order: Order): PrismaOrderCreateInput { return { id: order.id.value, customerId: order.customerId.value, status: order.status.toString(), shippingAddressLine1: order.shippingAddress.line1, shippingAddressLine2: order.shippingAddress.line2, shippingCity: order.shippingAddress.city, shippingPostalCode: order.shippingAddress.postalCode, items: order.items.map(i => this.orderItemMapper.toPersistence(i)), }; }} // The aggregate must support reconstitutionclass Order { // Factory for NEW orders - validates business rules static create(customerId: CustomerId, items: OrderItem[]): Order { if (items.length === 0) { throw new OrderMustHaveItemsError(); } // ... more validation return new Order(/* ... */); } // Reconstitution for EXISTING orders - trusts stored data static reconstitute(props: OrderProps): Order { // No business validation - data came from our storage return new Order( props.id, props.customerId, props.items, props.status, props.shippingAddress ); }}Aggregates typically need two construction paths: create() for new aggregates (with full validation) and reconstitute() for loading from storage (without revalidation). The reconstitute path trusts that stored data was valid when created.
Mapping Value Objects
Value objects often map to multiple database columns (flattened) or to separate tables (normalized). Choose based on query needs and storage efficiency.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Value Objectclass Money { constructor( readonly amount: number, readonly currency: Currency ) {}} // Option 1: Flattened - value object fields become table columns// Table: orders// price_amount DECIMAL// price_currency VARCHAR class MoneyMapper { toDomain(amount: number, currency: string): Money { return new Money(amount, Currency.fromCode(currency)); } toPersistence(money: Money): { amount: number; currency: string } { return { amount: money.amount, currency: money.currency.code, }; }} // Option 2: JSON column - store as JSON blob// Table: orders// price JSONB -- stores { "amount": 100, "currency": "USD" } class JsonMoneyMapper { toDomain(json: { amount: number; currency: string }): Money { return new Money(json.amount, Currency.fromCode(json.currency)); } toPersistence(money: Money): object { return { amount: money.amount, currency: money.currency.code, }; }} // Option 3: Separate table (for complex value objects)// Table: order_prices// order_id FK// amount DECIMAL// currency VARCHAR// exchange_rate DECIMAL// converted_amount DECIMALRepositories don't manage transactions directly—that's the Unit of Work's job. However, repository implementations must integrate correctly with the transaction infrastructure.
Unit of Work responsibilities:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Unit of Work interface (in domain layer)interface UnitOfWork { // Begin a new unit of work begin(): Promise<void>; // Commit all changes made during this unit of work commit(): Promise<void>; // Rollback all changes rollback(): Promise<void>; // Get repository scoped to this unit of work getOrderRepository(): OrderRepository; getCustomerRepository(): CustomerRepository;} // Implementation (in infrastructure layer)class SqlUnitOfWork implements UnitOfWork { private transaction: Transaction | null = null; private orderRepository: OrderRepository | null = null; constructor(private dataSource: DataSource) {} async begin(): Promise<void> { this.transaction = await this.dataSource.beginTransaction(); } async commit(): Promise<void> { if (!this.transaction) { throw new NoActiveTransactionError(); } await this.transaction.commit(); this.transaction = null; } async rollback(): Promise<void> { if (this.transaction) { await this.transaction.rollback(); this.transaction = null; } } getOrderRepository(): OrderRepository { if (!this.orderRepository) { this.orderRepository = new OrderRepositoryImpl( this.dataSource, this.transaction! ); } return this.orderRepository; }} // Usage in application serviceclass OrderApplicationService { constructor(private unitOfWorkFactory: () => UnitOfWork) {} async placeAndApproveOrder( customerId: CustomerId, items: OrderItem[] ): Promise<OrderId> { const uow = this.unitOfWorkFactory(); try { await uow.begin(); const orderRepo = uow.getOrderRepository(); // Create and add order const order = Order.create(customerId, items); await orderRepo.add(order); // Approve order (in same transaction) order.approve(); // Commit - both insert and update happen atomically await uow.commit(); return order.id; } catch (error) { await uow.rollback(); throw error; } }}ORM-Managed Transactions
Many ORMs provide built-in transaction and change tracking. In these cases, the Unit of Work pattern is often implicit in the ORM's DbContext/Session concept.
12345678910111213141516171819202122
// Prisma interactive transactionsclass PrismaOrderService { constructor(private prisma: PrismaClient) {} async placeAndApproveOrder( customerId: CustomerId, items: OrderItem[] ): Promise<OrderId> { // Prisma manages the transaction return this.prisma.$transaction(async (tx) => { const orderRepo = new OrderRepositoryImpl(tx); const order = Order.create(customerId, items); await orderRepo.add(order); order.approve(); // Prisma tracks changes automatically return order.id; }); }}A common mistake is committing per-repository operation. This breaks atomicity. All operations in a business transaction must share the same Unit of Work instance and commit together.
Repository abstractions shouldn't sacrifice performance. Understanding common performance pitfalls helps you implement efficient repositories.
N+1 Query Problem
The most common performance issue: loading an aggregate's root entity with one query, then issuing separate queries for each child entity.
123456789101112131415161718192021222324252627282930313233
// ❌ N+1 PROBLEMasync findById(orderId: OrderId): Promise<Order | null> { // Query 1: Load order const orderData = await this.prisma.order.findUnique({ where: { id: orderId.value } }); if (!orderData) return null; // Query 2..N: Load each item separately (implicit in some ORMs) // This happens if items are lazy-loaded const order = this.mapper.toDomain(orderData); // Accessing order.items triggers N more queries! return order;} // ✅ EAGER LOADING SOLUTIONasync findById(orderId: OrderId): Promise<Order | null> { // Single query with JOIN - loads everything at once const orderData = await this.prisma.order.findUnique({ where: { id: orderId.value }, include: { items: true, // Eager load items shippingAddress: true, // Eager load address } }); if (!orderData) return null; // Everything loaded - no additional queries return this.mapper.toDomain(orderData);}Projection for Read-Heavy Operations
Retrieving full aggregates is necessary for commands (modifications), but wasteful for queries that only need partial data. Use CQRS for read optimization.
123456789101112131415161718192021222324252627282930313233343536373839
// ❌ INEFFICIENT: Using repository for list displayasync displayOrderList(): Promise<OrderListItem[]> { // Loads FULL aggregates just to show summary const orders = await this.orderRepository.findByCustomer(customerId); // Throw away most of the data return orders.map(o => ({ id: o.id.value, date: o.createdAt, total: o.total.amount, status: o.status.displayName, }));} // ✅ EFFICIENT: Separate read model for queriesinterface OrderQueryService { findOrderSummariesForCustomer( customerId: CustomerId ): Promise<OrderSummary[]>;} class OrderQueryServiceImpl implements OrderQueryService { async findOrderSummariesForCustomer( customerId: CustomerId ): Promise<OrderSummary[]> { // Direct query returning only needed columns return this.prisma.$queryRaw` SELECT id, created_at, total_amount, status FROM orders WHERE customer_id = ${customerId.value} ORDER BY created_at DESC LIMIT 50 `; }} // Clear separation:// - Repository: for commands, loads full aggregates// - QueryService: for display, loads optimized projectionsCommand Query Responsibility Segregation (CQRS) separates write operations (using repositories and aggregates) from read operations (using query services and projections). This allows each to be optimized independently.
Caching Strategies
For aggregates that are read frequently but modified rarely, caching can significantly improve performance.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
class CachedOrderRepository implements OrderRepository { constructor( private innerRepository: OrderRepository, private cache: Cache ) {} async findById(orderId: OrderId): Promise<Order | null> { const cacheKey = `order:${orderId.value}`; // Check cache first const cached = await this.cache.get<Order>(cacheKey); if (cached) { return cached; } // Miss - load from database const order = await this.innerRepository.findById(orderId); if (order) { // Cache for future reads await this.cache.set(cacheKey, order, { ttl: 300 }); // 5 min } return order; } async add(order: Order): Promise<void> { await this.innerRepository.add(order); // Optionally warm cache await this.cache.set(`order:${order.id.value}`, order, { ttl: 300 }); } async remove(order: Order): Promise<void> { await this.innerRepository.remove(order); // Invalidate cache await this.cache.delete(`order:${order.id.value}`); } // For save operations, invalidate cache async save(order: Order): Promise<void> { await this.innerRepository.save(order); // Invalidate to force fresh load on next read await this.cache.delete(`order:${order.id.value}`); }}When multiple processes modify the same aggregate concurrently, conflicts arise. Repository implementations must handle this through optimistic concurrency control (most common) or pessimistic locking.
Optimistic Concurrency uses a version number or timestamp. When saving, the repository checks that the version hasn't changed since loading. If it has, someone else modified the aggregate, and we fail.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Aggregate with version trackingclass Order { private _version: number; get version(): number { return this._version; } // Version is incremented on modification cancel(): void { // ... business logic this._version++; } // Reconstitute includes version static reconstitute(props: OrderProps): Order { const order = new Order(/* ... */); order._version = props.version; return order; }} // Repository implementation with optimistic lockingclass OrderRepositoryImpl implements OrderRepository { async save(order: Order): Promise<void> { const result = await this.prisma.order.updateMany({ where: { id: order.id.value, version: order.version - 1, // Expected previous version }, data: { // ... all fields version: order.version, // New version }, }); if (result.count === 0) { // No rows updated = version mismatch throw new OptimisticLockException( `Order ${order.id.value} was modified by another transaction` ); } }} // Application layer handles the exceptionclass OrderApplicationService { async approveOrder(orderId: OrderId): Promise<void> { const maxRetries = 3; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const order = await this.orderRepository.findById(orderId); order.approve(); await this.orderRepository.save(order); return; // Success } catch (e) { if (e instanceof OptimisticLockException && attempt < maxRetries - 1) { continue; // Retry with fresh data } throw e; } } }}Always include a version or timestamp column on aggregates stored in SQL databases. Without it, you have no way to detect concurrent modifications, leading to lost updates.
Repository implementations need integration tests that verify correct interaction with the actual database. Unit tests with mocks aren't sufficient—you need to confirm that SQL, ORM mappings, and transactions work correctly.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
describe('OrderRepositoryImpl', () => { let prisma: PrismaClient; let repository: OrderRepository; beforeAll(async () => { // Use test database prisma = new PrismaClient({ datasources: { db: { url: process.env.TEST_DATABASE_URL } } }); await prisma.$connect(); }); beforeEach(async () => { // Clean database before each test await prisma.orderItem.deleteMany(); await prisma.order.deleteMany(); repository = new OrderRepositoryImpl(prisma, new OrderMapper()); }); afterAll(async () => { await prisma.$disconnect(); }); describe('add and findById', () => { it('should persist and retrieve a complete aggregate', async () => { // Arrange const order = Order.create( CustomerId.create('customer-123'), [ OrderItem.create(ProductId.create('product-1'), 2, Money.of(100)), OrderItem.create(ProductId.create('product-2'), 1, Money.of(50)), ] ); // Act await repository.add(order); const retrieved = await repository.findById(order.id); // Assert expect(retrieved).not.toBeNull(); expect(retrieved!.id.equals(order.id)).toBe(true); expect(retrieved!.items).toHaveLength(2); expect(retrieved!.total.amount).toBe(250); }); }); describe('findByCustomer', () => { it('should return only orders for the specified customer', async () => { // Arrange - create orders for multiple customers const customer1 = CustomerId.create('customer-1'); const customer2 = CustomerId.create('customer-2'); await repository.add(Order.create(customer1, [createItem()])); await repository.add(Order.create(customer1, [createItem()])); await repository.add(Order.create(customer2, [createItem()])); // Act const customer1Orders = await repository.findByCustomer(customer1); // Assert expect(customer1Orders).toHaveLength(2); expect(customer1Orders.every(o => o.customerId.equals(customer1))).toBe(true); }); }); describe('optimistic locking', () => { it('should throw when saving a stale aggregate', async () => { // Arrange const order = Order.create(customerId, [createItem()]); await repository.add(order); // Load the same order twice (simulating concurrent access) const order1 = await repository.findById(order.id); const order2 = await repository.findById(order.id); // Modify and save first instance order1!.approve(); await repository.save(order1!); // Modify second instance order2!.cancel(); // Act & Assert - second save should fail await expect(repository.save(order2!)) .rejects.toThrow(OptimisticLockException); }); });});Repository implementation bridges domain purity and infrastructure reality. Let's consolidate the key considerations:
Module Complete:
You've completed the Repositories module! You now understand:
You can now design and implement repositories that maintain the separation between domain logic and persistence infrastructure while achieving correct, performant data access. Next, explore Domain Events to see how aggregates communicate state changes.