Unit of Work
Unit of Work tracks changes to multiple aggregates during a business transaction and commits them atomically.
Introduction
Unit of Work tracks changes to multiple aggregates during a business transaction and commits them atomically. One use case — transfer money across accounts — must write to 4 tables or none. Unit of Work defines the transaction boundary so partial commits don't corrupt state.
The story
A banking "transfer money" use case wrote to 4 tables. Half-commits during DB outages caused months of reconciliation. Explicit Unit of Work with transaction boundaries eliminated partial-write incidents.
The business problem
Multiple repository calls without atomic boundary cause partial writes:
- Half-commits: debit succeeds, credit fails — money created/destroyed.
- Scattered @Transactional: transaction boundaries unclear per use case.
- Cross-aggregate inconsistency: invariants span multiple repos.
- Reconciliation cost: manual fixes for partial failure states.
The problem teams faced
Unit of Work fits when:
- Use case touches multiple aggregates/tables that must commit together.
- Partial failure must roll back all changes atomically.
- Repositories need shared persistence context (single DB session).
- Transaction management shouldn't leak into every repository method.
Understanding the topic
Intent: Maintain list of affected objects and coordinate writing changes in one atomic operation.
- UnitOfWork — registerNew/Dirty/Deleted; commit() flushes atomically.
- Repositories — share same UoW/session — don't auto-commit individually.
- Application service — opens UoW, orchestrates, commits or rolls back.
- @Transactional — framework UoW (Spring) at use case boundary.
Internal architecture
Transfer with Unit of Work:
class TransferMoneyUseCase {constructor(private uow: UnitOfWork) {}async execute(from: AccountId, to: AccountId, amount: Money) {await this.uow.begin()try {const a = await this.uow.accounts.findById(from)const b = await this.uow.accounts.findById(to)a.debit(amount); b.credit(amount)this.uow.registerDirty(a); this.uow.registerDirty(b)await this.uow.commit()} catch (e) {await this.uow.rollback()throw e}}}
Visual explanation
Three diagrams cover UoW boundary, commit/rollback, and vs repository alone:
Informative example
Before / after — separate commits to Unit of Work:
// ❌ Partial commit riskasync function transfer(from, to, amount) {await accountRepo.debit(from, amount) // commits immediatelyawait accountRepo.credit(to, amount) // fails — debit already committed}// ✅ Single transaction boundaryasync function transfer(from, to, amount) {return db.transaction(async (tx) => {await tx.debit(from, amount)await tx.credit(to, amount)}) // commit once or rollback all}
Execution workflow
Begin
Start transaction / UoW at use case entry.
Real-world use
Hibernate Session, Entity Framework DbContext, Spring @Transactional, SQL BEGIN/COMMIT blocks, TypeORM QueryRunner transactions.
Production case study
Banking transfer reconciliation elimination:
- Symptom: partial writes during outages; months of reconciliation.
- Decision: Unit of Work / explicit transaction per transfer use case.
- Outcome: zero partial-write incidents post-migration.
Trade-offs
- Pro: atomic multi-aggregate commits; clear transaction boundary.
- Con: long transactions if use case does slow I/O inside boundary.
- Con: doesn't solve cross-service atomicity — need Saga.
Decision framework
- Use when use case writes multiple aggregates in one DB.
- Use @Transactional at use case/service method boundary.
- Avoid long-running work inside transaction — I/O outside, commit fast.
- Cross-DB/service: Saga not UoW.
Best practices
- Transaction boundary = use case — not repository method.
- Keep transactions short — no HTTP calls inside @Transactional.
- Idempotent use cases when retry after ambiguous commit failure.
Anti-patterns to avoid
- @Transactional on every repository method — wrong granularity.
- Nested transactions without understanding propagation — surprises.
- UoW spanning HTTP request to external API — locks and timeouts.
Common mistakes
- Lazy loading outside transaction — LazyInitializationException class of bugs.
- Read-only operations marked transactional unnecessarily — connection hold.
Debugging tips
- Log transaction begin/commit/rollback with use case name.
- Trace which repo call triggered unexpected commit.
Optimization strategies
- Read-only @Transactional(readOnly=true) for query use cases.
Common misconceptions
- UoW ≠ Saga. UoW is single-DB atomicity. Saga is cross-service eventual consistency.
- DbContext is UoW in Entity Framework terminology.
Advanced interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1IntermediateQuestionUnit of Work vs Repository?+
Answer
Follow-up
2AdvancedQuestionWhere put @Transactional?+
Answer
Follow-up
3AdvancedQuestionUoW across microservices?+
Answer
Follow-up
Summary
You can define transaction boundaries per use case and prevent partial writes. Tell the banking reconciliation story — if you can do that, you own Unit of Work.