ADR 0008: Unit-of-Work Pattern for Transactional Flush
Status
Accepted
Context and Problem Statement
The repository abstraction (ITicketRepository) provides AddAsync to
stage a new entity. The question is: when and how does that staged change
become durable and visible to other requests?
Two possible answers: the repository commits immediately on every write, or writes are buffered until an explicit flush boundary is crossed. The choice determines whether a use-case handler controls its own commit point — and whether that contract can be tested and verified independently of the infrastructure.
Decision Drivers
- A use-case handler must be able to stage multiple writes and commit them as a single atomic operation.
- Uncommitted adds must not be visible to concurrent reads — not even within the same request scope.
- Both persistence implementations (EF Core and InMemory) must honour identical flush semantics so that end-to-end tests cover both without special-casing.
IUnitOfWorkbelongs to the application layer: when to commit is a use-case concern, not an infrastructure detail.
Considered Options
Option A — Explicit IUnitOfWork with a separate SaveChangesAsync (Selected)
IUnitOfWork is a single-method interface in Application.Abstractions.Persistence.
Repositories buffer writes (EF Core change tracker / InMemory PendingAdds).
The handler calls SaveChangesAsync explicitly after all writes are staged.
Writes that are never committed are invisible to all subsequent reads.
Option B — Implicit commit on each repository call
AddAsync commits immediately. No separate flush step, less interface
surface. But the handler loses the ability to stage multiple writes
atomically, and there is no clear signal in the code where persistence
actually occurs.
Option C — Ambient commit on scope disposal
The DI scope disposes the unit of work at the end of each request, triggering a flush automatically. Convenient but opaque: the commit point is invisible in the handler, making it harder to reason about partial failure and impossible to test without relying on scope disposal behaviour.
Decision Outcome
Chosen option: Option A — Explicit IUnitOfWork with a separate SaveChangesAsync.
Why this trade-off makes sense for this project
- The commit point is visible in the handler.
await _unitOfWork.SaveChangesAsync(ct)is an explicit statement of intent: everything staged before this line is written together, or not at all. There is no implicit flush hiding in infrastructure plumbing. IUnitOfWorklives inApplication, notInfrastructure. When a handler commits is a use-case decision. Placing the interface in the application layer enforces that — infrastructure provides the mechanism, application controls the when.- Both implementations honour the same flush boundary. EF Core's change
tracker defers writes until
SaveChanges. The InMemoryPendingAddslist does the same:AddAsyncappends to the list,SaveChangesAsyncapplies it to the singleton store atomically. This means the commit boundary is verified by the same test suite against both providers. - The boundary is testable and tested.
CommitBoundaryTestsandReadIsolationTestsrun with[ProviderMatrix]against both providers and verify three guarantees:- A committed ticket is visible in a subsequent scope.
- An uncommitted ticket is invisible in a subsequent scope.
- A staged but uncommitted ticket is invisible to reads within the same scope.
Consequences
Positive Consequences
- Partial failure within a handler is safe: if
SaveChangesAsyncis never reached (e.g. due to a domain validation failure earlier in the handler), staged writes are silently discarded with the scope. - The flush boundary is a single, auditable call site in each handler.
- The InMemory and EF Core implementations are behaviourally equivalent, allowing end-to-end tests to run against both without conditional logic.
Negative Consequences
- Every handler that writes must explicitly call
SaveChangesAsync. Missing this call is a silent failure — the handler returns success but nothing is persisted. - The separation of
ITicketRepositoryandIUnitOfWorkmeans two dependencies per handler instead of one, which adds minor boilerplate.
Re-evaluation Triggers
Revisit when:
- Multiple aggregates need to be written atomically within a single
handler — at that point the scope of
SaveChangesAsyncneeds to be defined explicitly (one UoW per handler vs. one per request). - Outbox or event-sourcing patterns are introduced, where the flush boundary must also publish domain events reliably.
Related
- ADR 0007 – Swappable Persistence via Runtime Provider Switch
src/ServiceDeskLite.Application/Abstractions/Persistence/IUnitOfWork.cssrc/ServiceDeskLite.Infrastructure/Persistence/UnitOfWork/EfUnitOfWork.cssrc/ServiceDeskLite.Infrastructure.InMemory/Persistence/InMemoryUnitOfWork.cstests/ServiceDeskLite.Tests.EndToEnd/Tickets/CommitBoundaryTests.cstests/ServiceDeskLite.Tests.EndToEnd/Tickets/ReadIsolationTests.cs