Unit of Work Pattern
Transaction Boundaries
Read aloud is unavailable in this browser.
Defining the Scope of Atomicity
In the previous page, we established that the Unit of Work pattern coordinates multiple changes into a single atomic operation. But this raises a critical design question: where exactly should transaction boundaries be drawn?
A transaction boundary defines the scope of atomicity—the set of operations that must all succeed or all fail together. Drawing these boundaries incorrectly leads to either:
- Too narrow: Multiple transactions for what should be a single business operation, risking partial completion
- Too broad: Holding locks too long, degrading performance and increasing deadlock risk
Finding the right transaction boundary is as much an architectural decision as it is a technical one. It requires understanding your business operations, consistency requirements, and performance constraints.
By the end of this page, you will understand how to identify natural transaction boundaries in your applications, the architectural patterns for where transactions should start and end, and how the Unit of Work pattern serves as the transaction boundary coordinator.
Understanding Transaction Boundaries
A transaction boundary is the demarcation point where a database transaction begins and ends. Everything between these points is part of the same atomic unit—it either all commits together or all rolls back together.
The Anatomy of a Transaction Boundary:
Key Properties of Well-Defined Transaction Boundaries:
Transaction Boundary Properties
- •Business Alignment: The boundary should correspond to a complete business operation, not an arbitrary code boundary
- •Minimum Lock Duration: The transaction should be open only as long as necessary to maintain consistency
- •Consistent Read View: All reads within the boundary see a consistent state of the database
- •Clear Ownership: One component (typically the Unit of Work) controls the transaction lifecycle
- •Failure Recovery: The boundary defines what gets rolled back on failure
Opening a transaction at the beginning of an HTTP request and committing at the end (Open Session in View pattern) creates overly long transactions that hold database locks for the entire request duration, including view rendering and network latency. This severely impacts database performance under load.
Transactions in Layered Architecture
In a typical layered architecture, the question of "where do transactions belong?" has significant design implications. Let's examine transaction ownership at each layer:
Where Should Transactions Be Managed?
| Layer | Transaction Ownership? | Reasoning |
|---|---|---|
| Presentation (Controller) | ❌ No | Too broad—includes request parsing, validation, rendering. Violates SRP. |
| Application Service | ✅ Yes (Ideal) | Orchestrates use cases, knows business boundaries, can coordinate Unit of Work |
| Domain Service | ⚠️ Sometimes | May own sub-transactions, but typically coordinated by Application layer |
| Repository | ❌ No | Too narrow—individual save operations can't ensure business consistency |
The Application Service as Transaction Owner
The Application Service layer is the ideal location for transaction boundary management because:
-
It Understands Use Cases: Application services orchestrate domain operations to fulfill business use cases. A single use case = a single transaction.
-
It Doesn't Know Infrastructure: While the service owns the transaction boundary, it doesn't know about connections, SQL, or database engines—that's delegated to the Unit of Work.
-
It Coordinates Aggregates: Business transactions often span multiple aggregates. The application service knows what aggregates participate.
-
It's Testable: Transaction logic in the application layer can be tested with mock/fake Unit of Work implementations.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Application Service - owns the transaction boundaryclass OrderApplicationService { constructor( private readonly unitOfWork: IUnitOfWork, private readonly orderDomainService: OrderDomainService ) {} async placeOrder(command: PlaceOrderCommand): Promise<OrderConfirmation> { // Transaction boundary STARTS here try { // Use repositories through Unit of Work const orderRepo = this.unitOfWork.orderRepository; const productRepo = this.unitOfWork.productRepository; const customerRepo = this.unitOfWork.customerRepository; // Load aggregates const customer = await customerRepo.findById(command.customerId); const products = await productRepo.findByIds(command.productIds); // Domain logic (pure, no persistence knowledge) const order = this.orderDomainService.createOrder( customer, products, command.quantities, command.shippingAddress ); // Validate domain rules order.validate(); // Register new entities orderRepo.add(order); // Update related entities (products' inventory) for (const item of order.items) { const product = products.find(p => p.id === item.productId); product?.decrementInventory(item.quantity); } // Commit all changes atomically await this.unitOfWork.commit(); // Transaction boundary ENDS here on success return new OrderConfirmation(order); } catch (error) { // Transaction boundary ENDS here on failure (rollback) await this.unitOfWork.rollback(); throw error; } }}Notice that the Domain Service (OrderDomainService) has no knowledge of transactions or persistence. It operates on domain objects and returns domain objects. The Application Service orchestrates the transaction boundary around the domain logic, keeping domain code pure and testable.
Transaction Boundary Patterns
There are several established patterns for defining and managing transaction boundaries. Each has its place depending on your architecture and requirements.
Pattern 1: Explicit Transaction Management
The application service explicitly starts and ends the transaction:
123456789101112131415161718192021222324252627282930
// Explicit transaction managementclass TransferFundsService { async transferFunds(command: TransferCommand): Promise<void> { // Explicitly begin await this.unitOfWork.begin(); try { const fromAccount = await this.unitOfWork.accountRepository .findById(command.fromAccountId); const toAccount = await this.unitOfWork.accountRepository .findById(command.toAccountId); fromAccount.withdraw(command.amount); toAccount.deposit(command.amount); const transfer = new Transfer(fromAccount, toAccount, command.amount); this.unitOfWork.transferRepository.add(transfer); // Explicitly commit await this.unitOfWork.commit(); } catch (error) { // Explicitly rollback await this.unitOfWork.rollback(); throw error; } }} // ✅ Full control over transaction lifecycle// ⚠️ Verbose, boilerplate in every service methodPattern 2: Transaction Decorator / Attribute
Use decorators or attributes to declaratively mark transactional methods:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Transaction decorator patternfunction Transactional(): MethodDecorator { return function ( target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const unitOfWork = (this as any).unitOfWork as IUnitOfWork; await unitOfWork.begin(); try { const result = await originalMethod.apply(this, args); await unitOfWork.commit(); return result; } catch (error) { await unitOfWork.rollback(); throw error; } }; return descriptor; };} // Usage:class OrderService { constructor(private unitOfWork: IUnitOfWork) {} @Transactional() // Decorator handles begin/commit/rollback async placeOrder(command: PlaceOrderCommand): Promise<Order> { // Just business logic - transaction handled by decorator const customer = await this.unitOfWork.customerRepository .findById(command.customerId); const order = new Order(customer, command.items); this.unitOfWork.orderRepository.add(order); return order; // Commit happens after this returns }} // ✅ Clean service code, declarative transactions// ⚠️ "Magic" behavior, harder to reason about edge casesPattern 3: Transaction Scope / Ambient Transaction
Use a transaction scope that automatically propagates through the call stack:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Transaction scope pattern (similar to .NET's TransactionScope)class TransactionScope { private static current: TransactionScope | null = null; private status: 'active' | 'committed' | 'rolledback' = 'active'; static get Current(): TransactionScope | null { return TransactionScope.current; } static async run<T>(fn: () => Promise<T>): Promise<T> { const scope = new TransactionScope(); const previousScope = TransactionScope.current; TransactionScope.current = scope; try { const result = await fn(); if (scope.status === 'active') { await scope.complete(); } return result; } catch (error) { if (scope.status === 'active') { await scope.abort(); } throw error; } finally { TransactionScope.current = previousScope; } } private async complete(): Promise<void> { // Commit to database this.status = 'committed'; } private async abort(): Promise<void> { // Rollback this.status = 'rolledback'; }} // Usage:async function processPayment(orderId: string): Promise<void> { await TransactionScope.run(async () => { // Everything in this closure is in the same transaction await paymentService.chargeCustomer(orderId); await orderService.markAsPaid(orderId); await notificationService.sendConfirmation(orderId); // Auto-commits if no exceptions });} // Inner calls automatically participate in outer transaction:class OrderService { async markAsPaid(orderId: string): Promise<void> { // Automatically uses TransactionScope.Current if available const scope = TransactionScope.Current; // ... operations participate in ambient transaction }}| Pattern | Verbosity | Control | Testability | Complexity |
|---|---|---|---|---|
| Explicit Management | High (boilerplate) | Complete | Good | Low |
| Decorator/Attribute | Low (declarative) | Limited | Medium | Medium |
| Transaction Scope | Medium | Medium | Medium | High |
| Unit of Work + DI | Low | Good | Excellent | Medium |
Unit of Work as Transaction Coordinator
The Unit of Work pattern provides an elegant solution for transaction boundary management by encapsulating transaction logic while remaining testable and flexible.
The Unit of Work Coordinates But Doesn't Control
An important distinction: the Unit of Work is the mechanism for transaction management, but the Application Service determines when to invoke it. This separation is crucial:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Unit of Work interface - the coordination mechanisminterface IUnitOfWork { // Repositories accessed through UoW readonly orderRepository: IOrderRepository; readonly productRepository: IProductRepository; readonly customerRepository: ICustomerRepository; // Transaction control begin(): Promise<void>; commit(): Promise<void>; rollback(): Promise<void>; // Optional: Savepoint support for nested transactions createSavepoint(name: string): Promise<void>; rollbackToSavepoint(name: string): Promise<void>;} // Implementation encapsulates transaction mechanicsclass EntityFrameworkUnitOfWork implements IUnitOfWork { private transaction: DatabaseTransaction | null = null; private readonly context: DbContext; constructor(contextFactory: () => DbContext) { this.context = contextFactory(); } get orderRepository(): IOrderRepository { return new OrderRepository(this.context); } get productRepository(): IProductRepository { return new ProductRepository(this.context); } get customerRepository(): ICustomerRepository { return new CustomerRepository(this.context); } async begin(): Promise<void> { this.transaction = await this.context.database.beginTransaction(); } async commit(): Promise<void> { // Persist all tracked changes await this.context.saveChanges(); // Commit the transaction if (this.transaction) { await this.transaction.commit(); this.transaction = null; } } async rollback(): Promise<void> { if (this.transaction) { await this.transaction.rollback(); this.transaction = null; } // Discard tracked changes this.context.changeTracker.clear(); }}Scoped Unit of Work Lifecycle
In dependency injection containers, the Unit of Work is typically registered as scoped (one instance per request/operation):
12345678910111213141516171819202122232425262728293031
// Dependency injection registrationclass AppModule { configureServices(services: ServiceCollection) { // Scoped: New instance per request, shared within request services.addScoped<IUnitOfWork, EntityFrameworkUnitOfWork>(); // Services are also scoped - they share the same UoW instance services.addScoped<OrderApplicationService>(); services.addScoped<PaymentApplicationService>(); // ⚠️ Warning: Repositories should NOT be registered separately // They should be accessed through the Unit of Work to share context }} // Controller receives services that share the same UoWclass OrderController { constructor( private orderService: OrderApplicationService, private paymentService: PaymentApplicationService ) {} async createOrder(request: CreateOrderRequest): Promise<Response> { // Both services use the same Unit of Work instance // Changes from orderService are visible to paymentService const order = await this.orderService.createOrder(request.orderData); await this.paymentService.processPayment(order.id, request.paymentData); return { orderId: order.id }; }}A scoped Unit of Work ensures all operations within a single request share the same database context and transaction. This enables the Identity Map pattern (preventing duplicate entity instances) and ensures all changes are committed together. A singleton UoW would be problematic for concurrent requests; a transient UoW would break change tracking across service calls.
Transaction Propagation Behavior
When services call other services, how should transactions behave? Should the inner service join the outer transaction, start its own, or run without a transaction? This is the problem of transaction propagation.
Common Propagation Behaviors:
| Propagation | Behavior | Use Case |
|---|---|---|
| REQUIRED | Join existing transaction, or create new if none exists | Default for most operations |
| REQUIRES_NEW | Always create a new transaction, suspending any existing one | Audit logs that must persist even if main transaction fails |
| SUPPORTS | Join if exists, run without transaction if none | Read-only queries |
| NOT_SUPPORTED | Suspend any existing transaction, run without one | Operations that must see committed data |
| MANDATORY | Join existing transaction, fail if none exists | Operations that should never run standalone |
| NEVER | Fail if a transaction exists | Operations that must not be transactional |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// Transaction propagation exampleenum TransactionPropagation { REQUIRED, REQUIRES_NEW, SUPPORTS, NOT_SUPPORTED, MANDATORY, NEVER} class TransactionalService { constructor( private unitOfWork: IUnitOfWork, private auditService: AuditService ) {} // REQUIRED: Join or create transaction (default) @Transactional({ propagation: TransactionPropagation.REQUIRED }) async transferFunds(from: string, to: string, amount: number): Promise<void> { const fromAccount = await this.unitOfWork.accounts.findById(from); const toAccount = await this.unitOfWork.accounts.findById(to); fromAccount.withdraw(amount); toAccount.deposit(amount); // Audit should persist even if transfer fails later await this.auditService.logTransfer(from, to, amount); }} class AuditService { // REQUIRES_NEW: Always create new transaction // This commits independently of the calling transaction @Transactional({ propagation: TransactionPropagation.REQUIRES_NEW }) async logTransfer(from: string, to: string, amount: number): Promise<void> { const auditEntry = new AuditEntry({ action: 'TRANSFER', details: { from, to, amount }, timestamp: new Date() }); await this.unitOfWork.auditEntries.add(auditEntry); await this.unitOfWork.commit(); // This commit is independent - even if outer transaction rolls back, // the audit entry is preserved }} // Execution flow:// 1. transferFunds starts Transaction A// 2. logTransfer starts Transaction B (suspending A)// 3. logTransfer commits Transaction B// 4. Transaction A resumes// 5. If transfer fails, Transaction A rolls back// But audit entry from Transaction B stays committed!Transaction propagation adds significant complexity. In most applications, REQUIRED is sufficient. Use REQUIRES_NEW sparingly—only when you genuinely need independent transaction outcomes. Overuse leads to subtle bugs where operations you expect to be atomic can partially commit.
Handling Failures at Transaction Boundaries
Transaction boundaries define not just what commits together, but also what fails together. Proper failure handling at boundaries is essential for correctness and good user experience.
Types of Failures at Transaction Boundaries:
Failure Categories
- •Domain Violations: Business rules prevent the operation (insufficient funds, invalid state transition)
- •Concurrency Conflicts: Another transaction modified the same data (optimistic locking failures)
- •Constraint Violations: Database constraints prevent the write (unique key, foreign key)
- •Infrastructure Failures: Connection lost, timeout, disk full
- •Programming Errors: Null references, type errors, assertion failures
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Comprehensive error handling at transaction boundariesclass OrderApplicationService { async placeOrder(command: PlaceOrderCommand): Promise<Result<Order, OrderError>> { try { await this.unitOfWork.begin(); // Domain validation const customer = await this.unitOfWork.customers.findById(command.customerId); if (!customer) { await this.unitOfWork.rollback(); return Result.failure(new OrderError('CUSTOMER_NOT_FOUND')); } // Create order const order = Order.create(customer, command.items); // Domain rule validation const validationResult = order.validate(); if (!validationResult.isValid) { await this.unitOfWork.rollback(); return Result.failure(new OrderError('VALIDATION_FAILED', validationResult.errors)); } // Check inventory (with optimistic concurrency) for (const item of order.items) { const product = await this.unitOfWork.products.findById(item.productId); if (product.availableQuantity < item.quantity) { await this.unitOfWork.rollback(); return Result.failure(new OrderError('INSUFFICIENT_INVENTORY', { productId: item.productId, requested: item.quantity, available: product.availableQuantity })); } product.reserve(item.quantity); } this.unitOfWork.orders.add(order); // Commit await this.unitOfWork.commit(); return Result.success(order); } catch (error) { await this.unitOfWork.rollback(); // Translate infrastructure exceptions to domain errors if (error instanceof ConcurrencyException) { return Result.failure(new OrderError( 'CONCURRENCY_CONFLICT', 'Another user modified this data. Please refresh and try again.' )); } if (error instanceof UniqueConstraintViolation) { return Result.failure(new OrderError( 'DUPLICATE_ORDER', 'An order with this reference already exists.' )); } if (error instanceof ConnectionException) { // Log for monitoring this.logger.error('Database connection failed', error); return Result.failure(new OrderError( 'SERVICE_UNAVAILABLE', 'Service temporarily unavailable. Please try again.' )); } // Unexpected error - rethrow for global handler this.logger.error('Unexpected error in placeOrder', error); throw error; } }}Using Result<Success, Failure> instead of throwing exceptions for expected failures (like validation errors) makes transaction boundary handling cleaner. Exceptions remain for truly exceptional situations (infrastructure failures, programming errors), while business rule violations flow through the normal return path.
Best Practices for Transaction Boundaries
Drawing on the concepts covered, here are the key best practices for defining and managing transaction boundaries:
- •Align with Business Operations: One business use case should equal one transaction. Don't split a logical operation across transactions.
- •Keep Transactions Short: Don't hold transactions open during user interactions, external API calls, or any I/O that could take variable time.
- •Own at Application Layer: Let application services control transaction boundaries, not controllers or repositories.
- •Make Domain Layer Unaware: Domain entities and services should not know about transactions. They operate on objects; the application layer decides atomicity.
- •Use Scoped Unit of Work: Register Unit of Work as scoped per request to ensure proper sharing and cleanup.
- •Handle All Exit Paths: Every code path must either commit or rollback—use try/finally or decorators to guarantee cleanup.
- •Fail Fast, Fail Clearly: Validate early, before modifying state. Return meaningful errors, not internal exception details.
- •Minimize Lock Contention: Order operations to reduce deadlock risk. Access tables in consistent order across transactions.
- •Open Session in View (transaction for whole request)
- •Auto-commit mode for multi-step operations
- •Manual connection management in application code
- •Transaction logic in domain entities
- •Ignoring rollback on exception paths
- •Long-running transactions with user wait states
- •Unit of Work per request/operation
- •Explicit begin/commit/rollback in services
- •Result types for expected failures
- •Transaction-unaware domain layer
- •Scoped DI registration for UoW
- •Optimistic concurrency for conflict detection
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.