Unit of Work: concepts, trade-offs and production lessons
When you're processing an order that involves creating a payment record, updating inventory, and logging a transaction, the last thing you want is for two of those operations to succeed while the third fails. The Unit of Work pattern ensures that multiple data changes across different repositories stay together—either they all commit or none of them do. This is a foundational pattern that shapes how we structure data persistence in production systems, and getting it right pays dividends when edge cases inevitably occur.
What is Unit of Work?
At its core, the Unit of Work pattern is a transaction wrapper. It tracks every change you make to entities across multiple repositories during a single use case, then commits all those changes in one atomic database transaction at the very end. Think of it as holding a batch of operations in a staging area until you're confident everything is ready to persist.
The pattern solves a real problem: without it, you'd end up calling SaveAsync() on different repositories multiple times throughout your business logic, creating implicit transaction boundaries scattered everywhere. One fails partway through? Now you're debugging partial state changes and trying to figure out which repositories succeeded and which didn't.
Tracks changes across repositories
In a typical order processing workflow, you might need to interact with an OrderRepository, a PaymentRepository, and an InventoryRepository. Without Unit of Work, your code looks like this:
async processOrder(orderId: string) {
const order = await this.orderRepo.getById(orderId);
order.status = 'processing';
await this.orderRepo.save(order); // First commit
const payment = new Payment(order.id, order.total);
await this.paymentRepo.save(payment); // Second commit
await this.inventoryRepo.decrementStock(order.items); // Third commit
}
Each save() call is a separate transaction. If the third one fails, you've already committed changes that should have been rolled back together.
With Unit of Work, you register changes without committing them:
async processOrder(orderId: string) {
const order = await this.unitOfWork.orders.getById(orderId);
order.status = 'processing';
const payment = new Payment(order.id, order.total);
this.unitOfWork.payments.register(payment);
this.unitOfWork.inventory.decrementStock(order.items);
// All changes tracked, but nothing committed yet
await this.unitOfWork.commit(); // One transaction for all
}
Now the database sees all three operations as a single atomic unit. Either they all succeed or all fail—no middle ground.
One commit at the end of a use case
The pattern enforces a clear pattern: track changes throughout your use case logic, then commit everything at the boundary. This is where the pattern really shines in practice. Your business logic becomes cleaner because you're not mixing persistence concerns into the middle of your workflow.
Consider a transfer between bank accounts. You debit one account and credit another. If you commit after each step, a network failure between the two operations leaves the system in an inconsistent state. With Unit of Work:
async transferFunds(fromId: string, toId: string, amount: number) {
const from = await this.unitOfWork.accounts.getById(fromId);
const to = await this.unitOfWork.accounts.getById(toId);
// Modify both in memory
from.balance -= amount;
to.balance += amount;
// Create audit log entry
const transaction = new AccountTransaction({
fromId,
toId,
amount,
timestamp: new Date(),
});
this.unitOfWork.transactions.register(transaction);
// One transaction wraps everything
await this.unitOfWork.commit();
}
If anything fails during commit(), nothing persists. The accounts remain unchanged. This is the guarantee we need for financial operations.
The key insight is that your use case method becomes the natural scope for Unit of Work. Your controller or handler calls one method, which orchestrates all the changes, and either succeeds completely or fails cleanly.
DbContext already implements it
If you're working in .NET with Entity Framework Core, you already have Unit of Work built in. DbContext is a Unit of Work implementation. Every entity you load stays tracked in the context's identity map. You modify them in memory, and await context.SaveChangesAsync() is the commit.
async updateOrderAndPayment(orderId: string) {
const order = await context.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
const payment = await context.Payments.FirstOrDefaultAsync(p => p.OrderId == orderId);
order.UpdatedAt = DateTime.UtcNow;
payment.Status = "verified";
// Both changes tracked by context
// Single transaction when SaveChangesAsync runs
await context.SaveChangesAsync();
}
The context handles the transaction internally. You don't explicitly manage begin, commit, or rollback unless you need to customize behavior.
In NestJS with TypeORM or Prisma, you need to be more explicit. TypeORM's QueryRunner or a custom Unit of Work class becomes necessary. Prisma doesn't have built-in transaction support across multiple operations the way EF Core does, so many teams wrap Prisma calls in a custom repository layer that simulates Unit of Work semantics.
The important thing: whether your ORM gives it to you for free or you build a thin wrapper, the pattern is consistent. Track changes, commit once.
Keeps transaction boundaries explicit
Explicit boundaries matter more than you might think. When someone reads your code six months from now—or when you debug a production issue at 2 AM—it should be immediately clear what changes might roll back together.
Compare these two approaches:
Implicit boundaries (scattered commits)
await this.orderRepo.save(order);
await this.paymentRepo.save(payment);
await this.inventoryRepo.save(inventory);
// Which of these rolls back together? No clear answer.
Explicit boundaries (Unit of Work)
this.unitOfWork.orders.register(order);
this.unitOfWork.payments.register(payment);
this.unitOfWork.inventory.register(inventory);
await this.unitOfWork.commit();
// Clear: everything between register calls and commit is one transaction.
Explicit boundaries also help with debugging. When you're tracing a bug where the database is in a bad state, you need to know exactly which changes were supposed to be atomic. Scattered commits make that analysis a guessing game.
In high-concurrency scenarios like ride-hailing or trading, explicit boundaries become critical. You might have a use case that needs to touch five different aggregates. Without clear boundaries, it's easy to accidentally introduce race conditions because you're not protecting the full scope of operations.
Common mistakes
Mistake 1: Committing inside nested function calls. If your Unit of Work commits at multiple levels, you lose the atomicity guarantee. A helper function that calls commit() means the outer function's changes might not roll back together with the inner function's changes.
Mistake 2: Holding transactions too long. Unit of Work should live for the duration of a single use case, not a user session or request batch. Keeping a transaction open while waiting for user input or external API responses causes lock contention and can deadlock other parts of your system.
Mistake 3: Mixing Unit of Work with explicit SaveChanges calls. If you're using EF Core's DbContext, calling SaveChangesAsync() in the middle of your use case breaks the pattern. Either commit everything at the end or not at all.
Mistake 4: Not handling commit failures. Network timeouts, deadlocks, and constraint violations can all happen during commit(). Your code needs to catch these exceptions and decide whether to retry, log for manual intervention, or propagate to the caller. Silently swallowing commit errors is a data consistency disaster waiting to happen.
Key takeaways
- Unit of Work collects changes across multiple repositories and commits them as one atomic database transaction.
- Track changes throughout your use case logic, then commit everything at a single boundary—typically at the end of a controller or handler method.
- DbContext in .NET already implements Unit of Work; you may need custom wrappers in Node.js ecosystems depending on your ORM.
- Explicit transaction boundaries make code easier to read, easier to debug, and safer in concurrent systems.
- Commit early, keep transactions short, and handle commit failures explicitly—these practices prevent data corruption and lock contention in production.






Comments
Loading comments…