Loading content...
The Unit of Work and Repository patterns are complementary—they work together to create a clean, testable, and maintainable persistence layer. While the previous pages focused on the Unit of Work in isolation, production applications require thoughtful integration between these two patterns.
This page explores how to combine Unit of Work with Repository pattern effectively, examining ownership models, aggregate considerations, and practical integration strategies that scale from simple CRUD applications to complex domain-driven architectures.
By the end of this page, you will understand how to integrate Unit of Work with repositories, different repository access patterns, how aggregates influence repository design, testing strategies for the integrated persistence layer, and common pitfalls to avoid.
Before diving into integration strategies, let's clarify what each pattern is responsible for:
Repository Pattern Responsibilities:
Unit of Work Responsibilities:
The key insight is that these patterns address different concerns of the same problem:
| Concern | Repository Handles | Unit of Work Handles |
|---|---|---|
| Finding entities | ✅ Query logic, mapping | ❌ Not involved |
| Adding new entities | ✅ Registration interface | ✅ Tracking state, commit |
| Updating entities | ⚠️ May not have explicit API | ✅ Detects changes, generates UPDATE |
| Deleting entities | ✅ Removal interface | ✅ Tracking deletion, commit |
| Transaction scope | ❌ Not aware | ✅ Owns transaction boundary |
| Multiple repositories | ❌ Isolated per type | ✅ Coordinates across all |
Some developers wonder whether to use Repository OR Unit of Work. In most architectures, you use both: Repositories for data access abstraction and Unit of Work for change coordination. They serve different purposes and complement each other.
There are several architectural patterns for how application code accesses repositories in relation to the Unit of Work. Each has trade-offs:
Pattern 1: Repositories as Properties of Unit of Work
The most common pattern—repositories are accessed through the Unit of Work:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Pattern 1: Repositories as UoW propertiesinterface IUnitOfWork { readonly orders: IOrderRepository; readonly products: IProductRepository; readonly customers: ICustomerRepository; commit(): Promise<void>; rollback(): Promise<void>;} class UnitOfWork implements IUnitOfWork { private _orders?: OrderRepository; private _products?: ProductRepository; private _customers?: CustomerRepository; private changeTracker: ChangeTracker; get orders(): IOrderRepository { return this._orders ??= new OrderRepository(this.changeTracker); } get products(): IProductRepository { return this._products ??= new ProductRepository(this.changeTracker); } get customers(): ICustomerRepository { return this._customers ??= new CustomerRepository(this.changeTracker); }} // Usage in application service:class OrderService { constructor(private unitOfWork: IUnitOfWork) {} async placeOrder(command: PlaceOrderCommand): Promise<Order> { // Access repositories through UoW const customer = await this.unitOfWork.customers.findById(command.customerId); const products = await this.unitOfWork.products.findByIds(command.productIds); const order = Order.create(customer, products, command.quantities); this.unitOfWork.orders.add(order); await this.unitOfWork.commit(); return order; }} // ✅ Guarantees all repositories share same change tracker// ✅ Clear ownership hierarchy// ⚠️ UoW interface grows with each new repositoryPattern 2: Repository Factory
Use a factory to create repositories that share the same Unit of Work context:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Pattern 2: Repository Factoryinterface IRepositoryFactory { createOrderRepository(): IOrderRepository; createProductRepository(): IProductRepository; createCustomerRepository(): ICustomerRepository;} class UnitOfWorkRepositoryFactory implements IRepositoryFactory { constructor( private changeTracker: ChangeTracker, private connection: DatabaseConnection ) {} createOrderRepository(): IOrderRepository { return new OrderRepository(this.changeTracker, this.connection); } createProductRepository(): IProductRepository { return new ProductRepository(this.changeTracker, this.connection); } createCustomerRepository(): ICustomerRepository { return new CustomerRepository(this.changeTracker, this.connection); }} // Unit of Work exposes factoryinterface IUnitOfWork { readonly repositoryFactory: IRepositoryFactory; commit(): Promise<void>; rollback(): Promise<void>;} // Usage:class OrderService { constructor(private unitOfWork: IUnitOfWork) {} async placeOrder(command: PlaceOrderCommand): Promise<Order> { const orderRepo = this.unitOfWork.repositoryFactory.createOrderRepository(); const customerRepo = this.unitOfWork.repositoryFactory.createCustomerRepository(); // ... rest of logic }} // ✅ More flexible - can create custom repositories// ✅ UoW interface stays stable// ⚠️ More verbose - need to call factory methodsPattern 3: Generic Repository with Unit of Work
For CRUD-heavy applications, a generic repository approach:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Pattern 3: Generic Repository Accessinterface IUnitOfWork { getRepository<T extends Entity>(entityType: EntityType<T>): IRepository<T>; commit(): Promise<void>; rollback(): Promise<void>;} interface IRepository<T> { findById(id: string): Promise<T | null>; findAll(): Promise<T[]>; findBy(specification: Specification<T>): Promise<T[]>; add(entity: T): void; remove(entity: T): void;} class UnitOfWork implements IUnitOfWork { private repositoryCache: Map<string, IRepository<any>> = new Map(); getRepository<T extends Entity>(entityType: EntityType<T>): IRepository<T> { const typeName = entityType.name; if (!this.repositoryCache.has(typeName)) { this.repositoryCache.set( typeName, new GenericRepository<T>(entityType, this.changeTracker) ); } return this.repositoryCache.get(typeName)!; }} // Usage:class OrderService { async placeOrder(command: PlaceOrderCommand): Promise<Order> { const orderRepo = this.unitOfWork.getRepository(Order); const productRepo = this.unitOfWork.getRepository(Product); // Generic interface for common operations const products = await productRepo.findBy( new ProductIdSpecification(command.productIds) ); const order = Order.create(/*...*/); orderRepo.add(order); await this.unitOfWork.commit(); return order; }} // ✅ No explicit repository classes for simple CRUD// ✅ Consistent interface across all entities// ⚠️ Custom query methods require extending| Pattern | Best For | Trade-offs |
|---|---|---|
| Properties on UoW | Most applications, clear API | UoW interface grows with repositories |
| Factory Pattern | Large number of repositories | More verbose, extra indirection |
| Generic Repository | CRUD-heavy, uniform access | Less type safety, custom queries harder |
In Domain-Driven Design, aggregates define consistency boundaries. This has profound implications for how repositories and the Unit of Work interact.
The Aggregate Rule:
Repositories should only exist for aggregate roots. Non-root entities within an aggregate should be accessed through their aggregate root.
This rule affects both repository design and Unit of Work behavior:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// ✅ CORRECT: Repository for aggregate root onlyinterface IOrderRepository { findById(id: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>; add(order: Order): void; remove(order: Order): void;} // The Order aggregate root includes its itemsclass Order { private id: OrderId; private customerId: CustomerId; // Reference by ID, not object private items: OrderItem[] = []; // Owned by Order addItem(productId: ProductId, quantity: number, price: Money): void { const item = new OrderItem(this.id, productId, quantity, price); this.items.push(item); } removeItem(itemId: OrderItemId): void { this.items = this.items.filter(i => !i.id.equals(itemId)); }} // ❌ INCORRECT: No separate repository for OrderItem// interface IOrderItemRepository {} // Don't do this! // Usage - Order controls its itemsclass OrderApplicationService { async addItemToOrder(orderId: string, productId: string, qty: number): Promise<void> { const order = await this.unitOfWork.orders.findById(new OrderId(orderId)); if (!order) throw new OrderNotFoundError(orderId); const product = await this.unitOfWork.products.findById(new ProductId(productId)); if (!product) throw new ProductNotFoundError(productId); // Order manages its own items order.addItem(product.id, qty, product.currentPrice); // Unit of Work tracks Order, which includes its items await this.unitOfWork.commit(); // Commits: UPDATE orders, INSERT order_items (if new), etc. }}Unit of Work and Aggregate Persistence:
When persisting aggregates, the Unit of Work must handle the entire object graph:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Change tracker must traverse aggregate relationshipsclass AggregateAwareChangeTracker extends ChangeTracker { /** * When tracking an aggregate root, also track its children */ trackLoaded<T>(entity: T): void { super.trackLoaded(entity); const metadata = this.getMetadata(entity); // Recursively track owned entities (one-to-many, one-to-one owned) for (const relationship of metadata.relationships) { if (relationship.isOwned) { // Part of aggregate, not just reference const children = this.getRelatedEntities(entity, relationship); for (const child of children) { this.trackLoaded(child); } } } } /** * Detect changes in the entire aggregate */ detectAggregateChanges<T>(root: T): AggregateChanges { const changes: AggregateChanges = { rootModified: this.hasChanges(root), addedChildren: [], modifiedChildren: [], deletedChildren: [] }; const metadata = this.getMetadata(root); for (const relationship of metadata.relationships) { if (relationship.isOwned) { const currentChildren = this.getRelatedEntities(root, relationship); const originalChildIds = this.getOriginalChildIds(root, relationship); for (const child of currentChildren) { const childId = this.getId(child); if (!originalChildIds.has(childId)) { changes.addedChildren.push(child); } else if (this.hasChanges(child)) { changes.modifiedChildren.push(child); } } // Find deleted children for (const origId of originalChildIds) { if (!currentChildren.some(c => this.getId(c) === origId)) { changes.deletedChildren.push({ type: relationship.targetType, id: origId }); } } } } return changes; }}When loading an aggregate, you have options: eager load the entire aggregate (simple but potentially heavy), lazy load children on access (risk of N+1 queries), or use explicit loading (caller decides what to include). Most ORMs support all three approaches.
The integration point between Unit of Work and repositories is an excellent place to handle cross-cutting concerns like auditing, validation, and domain events.
Audit Logging:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Audit-aware Unit of Workclass AuditableUnitOfWork implements IUnitOfWork { private inner: IUnitOfWork; private auditLog: IAuditLog; private currentUser: User; constructor(inner: IUnitOfWork, auditLog: IAuditLog, currentUser: User) { this.inner = inner; this.auditLog = auditLog; this.currentUser = currentUser; } async commit(): Promise<void> { const changes = this.inner.getChanges(); // Create audit entries for all changes for (const entry of changes.inserts) { await this.auditLog.log({ entityType: entry.entityType.name, entityId: this.getId(entry.entity), action: 'CREATE', newValues: this.serializeEntity(entry.entity), userId: this.currentUser.id, timestamp: new Date() }); } for (const entry of changes.updates) { await this.auditLog.log({ entityType: entry.entityType.name, entityId: this.getId(entry.entity), action: 'UPDATE', oldValues: this.mapToObject(entry.originalValues), newValues: this.serializeEntity(entry.entity), userId: this.currentUser.id, timestamp: new Date() }); } for (const entry of changes.deletes) { await this.auditLog.log({ entityType: entry.entityType.name, entityId: this.getId(entry.entity), action: 'DELETE', oldValues: this.serializeEntity(entry.entity), userId: this.currentUser.id, timestamp: new Date() }); } // Commit the actual changes await this.inner.commit(); }}Domain Event Publishing:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Domain event-aware Unit of Workclass EventPublishingUnitOfWork implements IUnitOfWork { private inner: IUnitOfWork; private eventDispatcher: IDomainEventDispatcher; async commit(): Promise<void> { // Collect all domain events from entities const events: DomainEvent[] = []; for (const entry of this.inner.getTrackedEntities()) { if (entry.entity instanceof AggregateRoot) { events.push(...entry.entity.getDomainEvents()); } } // Commit first (ensures all data is persisted) await this.inner.commit(); // Then dispatch events (after successful commit) for (const event of events) { await this.eventDispatcher.dispatch(event); } // Clear events from entities for (const entry of this.inner.getTrackedEntities()) { if (entry.entity instanceof AggregateRoot) { entry.entity.clearDomainEvents(); } } }} // Aggregate root base class with event supportabstract class AggregateRoot<TId> { private domainEvents: DomainEvent[] = []; protected addDomainEvent(event: DomainEvent): void { this.domainEvents.push(event); } getDomainEvents(): DomainEvent[] { return [...this.domainEvents]; } clearDomainEvents(): void { this.domainEvents = []; }} // Usage in domain entity:class Order extends AggregateRoot<OrderId> { place(): void { this.status = OrderStatus.Placed; this.placedAt = new Date(); // Raise domain event this.addDomainEvent(new OrderPlacedEvent(this.id, this.customerId, this.total)); }}Events should be dispatched AFTER successful commit, not before. If commit fails, you don't want events that reference uncommitted data. Some architectures use outbox pattern: events are stored in the same transaction, then published asynchronously by a separate process.
One of the primary benefits of the Unit of Work and Repository patterns is improved testability. Here are strategies for testing at different levels:
Unit Testing with In-Memory Implementations:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// In-memory Unit of Work for testingclass InMemoryUnitOfWork implements IUnitOfWork { private committed: boolean = false; public readonly orders: InMemoryOrderRepository; public readonly products: InMemoryProductRepository; public readonly customers: InMemoryCustomerRepository; constructor() { const tracker = new InMemoryChangeTracker(); this.orders = new InMemoryOrderRepository(tracker); this.products = new InMemoryProductRepository(tracker); this.customers = new InMemoryCustomerRepository(tracker); } async commit(): Promise<void> { // In-memory: just mark as committed // Changes are already reflected in the collections this.committed = true; } async rollback(): Promise<void> { // Could implement actual rollback if needed this.committed = false; } wasCommitted(): boolean { return this.committed; }} class InMemoryOrderRepository implements IOrderRepository { private orders: Map<string, Order> = new Map(); async findById(id: string): Promise<Order | null> { return this.orders.get(id) || null; } add(order: Order): void { this.orders.set(order.id, order); } remove(order: Order): void { this.orders.delete(order.id); } // Test helpers getAll(): Order[] { return Array.from(this.orders.values()); } seed(orders: Order[]): void { for (const order of orders) { this.orders.set(order.id, order); } }}Writing Unit Tests:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// Unit test exampledescribe('OrderApplicationService', () => { let unitOfWork: InMemoryUnitOfWork; let service: OrderApplicationService; beforeEach(() => { unitOfWork = new InMemoryUnitOfWork(); service = new OrderApplicationService(unitOfWork); }); describe('placeOrder', () => { it('should create order with items and commit', async () => { // Arrange: Seed test data const customer = new Customer('cust-1', 'John Doe'); const product = new Product('prod-1', 'Widget', Money.of(25)); unitOfWork.customers.seed([customer]); unitOfWork.products.seed([product]); // Act const command = new PlaceOrderCommand({ customerId: 'cust-1', items: [{ productId: 'prod-1', quantity: 2 }] }); const result = await service.placeOrder(command); // Assert expect(result.isSuccess).toBe(true); expect(unitOfWork.wasCommitted()).toBe(true); const savedOrders = unitOfWork.orders.getAll(); expect(savedOrders).toHaveLength(1); expect(savedOrders[0].items).toHaveLength(1); expect(savedOrders[0].items[0].quantity).toBe(2); }); it('should not commit if customer not found', async () => { // Act const command = new PlaceOrderCommand({ customerId: 'non-existent', items: [{ productId: 'prod-1', quantity: 1 }] }); const result = await service.placeOrder(command); // Assert expect(result.isFailure).toBe(true); expect(result.error.code).toBe('CUSTOMER_NOT_FOUND'); expect(unitOfWork.wasCommitted()).toBe(false); expect(unitOfWork.orders.getAll()).toHaveLength(0); }); it('should rollback on inventory error', async () => { // Arrange: Product with insufficient inventory const customer = new Customer('cust-1', 'John Doe'); const product = new Product('prod-1', 'Widget', Money.of(25)); product.setInventory(1); // Only 1 available unitOfWork.customers.seed([customer]); unitOfWork.products.seed([product]); // Act: Try to order 10 const command = new PlaceOrderCommand({ customerId: 'cust-1', items: [{ productId: 'prod-1', quantity: 10 }] }); const result = await service.placeOrder(command); // Assert expect(result.isFailure).toBe(true); expect(result.error.code).toBe('INSUFFICIENT_INVENTORY'); expect(unitOfWork.wasCommitted()).toBe(false); }); });});Integration Testing with Real Database:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Integration test with real databasedescribe('OrderApplicationService Integration', () => { let unitOfWorkFactory: () => IUnitOfWork; beforeAll(async () => { // Setup test database await TestDatabase.setup(); unitOfWorkFactory = () => new UnitOfWork(TestDatabase.createConnection()); }); afterAll(async () => { await TestDatabase.teardown(); }); beforeEach(async () => { // Clean data between tests await TestDatabase.truncateAll(); }); it('should persist order across repository boundaries', async () => { const unitOfWork = unitOfWorkFactory(); const service = new OrderApplicationService(unitOfWork); // Seed data using the same UoW const customer = new Customer('cust-1', 'Jane Doe'); unitOfWork.customers.add(customer); const product = new Product('prod-1', 'Gadget', Money.of(99.99)); product.setInventory(100); unitOfWork.products.add(product); await unitOfWork.commit(); // Create order const freshUnitOfWork = unitOfWorkFactory(); const freshService = new OrderApplicationService(freshUnitOfWork); const result = await freshService.placeOrder(new PlaceOrderCommand({ customerId: 'cust-1', items: [{ productId: 'prod-1', quantity: 5 }] })); expect(result.isSuccess).toBe(true); // Verify with yet another UoW (different connection) const verifyUnitOfWork = unitOfWorkFactory(); const savedOrder = await verifyUnitOfWork.orders.findById(result.value.id); expect(savedOrder).not.toBeNull(); expect(savedOrder!.items).toHaveLength(1); expect(savedOrder!.items[0].quantity).toBe(5); // Verify inventory was decremented const product2 = await verifyUnitOfWork.products.findById('prod-1'); expect(product2!.inventory).toBe(95); // 100 - 5 });});Use mostly unit tests with in-memory implementations (fast, isolated), fewer integration tests with real database (verify SQL/mapping), and minimal end-to-end tests (verify full stack). The abstraction provided by Unit of Work and Repository patterns enables this pyramid.
The integration of Unit of Work and Repository patterns can introduce subtle issues. Here are common pitfalls and how to avoid them:
1234567891011121314151617181920
// ❌ WRONG: Creating new UoW per repositoryclass OrderService { async placeOrder(command: PlaceOrderCommand): Promise<void> { const orderUoW = new UnitOfWork(); // New instance const productUoW = new UnitOfWork(); // Different instance! // These don't share change tracker - disaster! }} // ✅ CORRECT: Injected scoped UoWservices.addScoped<IUnitOfWork, UnitOfWork>(); class OrderService { constructor(private unitOfWork: IUnitOfWork) {} // Injected, shared async placeOrder(command: PlaceOrderCommand): Promise<void> { // All repositories from unitOfWork share same context }}We've explored how to effectively integrate the Unit of Work and Repository patterns. Let's consolidate the key insights:
Congratulations! You've completed the Unit of Work Pattern module. You now understand how to manage multiple database changes atomically, define appropriate transaction boundaries, implement a Unit of Work from scratch, and integrate it effectively with the Repository pattern. These skills are essential for building robust, maintainable persistence layers in enterprise applications.