Loading content...
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:
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.
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:
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.
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.
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 |
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.
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.
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:
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.
Drawing on the concepts covered, here are the key best practices for defining and managing transaction boundaries:
We've explored how to define and manage transaction boundaries in applications using the Unit of Work pattern. Let's consolidate the key insights:
What's Next:
Now that we understand where transaction boundaries should be drawn, the next page dives into implementing the Unit of Work pattern—building a concrete implementation that handles change tracking, commit orchestration, and integration with database transactions.
You now understand how to identify and manage transaction boundaries in layered architectures, the role of the application layer in owning these boundaries, and how the Unit of Work pattern serves as the coordination mechanism. Next, we'll build a complete Unit of Work implementation.