Class EfUnitOfWork<TContext>
- Namespace
- Trellis.EntityFrameworkCore
- Assembly
- Trellis.EntityFrameworkCore.dll
EF Core implementation of Trellis.IUnitOfWork and ITrackedAggregateSource. Delegates to SaveChangesResultUnitAsync(DbContext, CancellationToken) which already maps DbUpdateConcurrencyException to Error.Conflict, duplicate-key exceptions to Error.Conflict, and foreign-key violations to Error.Conflict.
public class EfUnitOfWork<TContext> : IUnitOfWork, ITrackedAggregateSource where TContext : DbContext
Type Parameters
TContextThe concrete DbContext type registered in DI.
- Inheritance
-
EfUnitOfWork<TContext>
- Implements
-
IUnitOfWork
- Inherited Members
- Extension Methods
Remarks
Nested-scope tracking. Each call to BeginScope() increments an internal
depth counter; the matching Dispose decrements it. CommitAsync(CancellationToken) defers
(returns success without touching the database) when the depth is greater than one — only
the outermost scope's commit actually persists changes. This makes a successful inner
command's commit a no-op so a failing outer command can still abort, addressing the GPT-5.5
review's "nested commands commit too early" finding.
Tracked aggregate snapshot. Each outermost CommitAsync(CancellationToken) clears the previous snapshot, captures every IAggregate entity the change tracker holds (in any state — Added, Modified, Unchanged, Deleted) immediately before calling SaveChangesAsync(CancellationToken), and assigns the snapshot to CommittedAggregates only after the save returns success. Failed commits and commits that throw (cancellation, connection failure, etc.) leave CommittedAggregates as the empty list. Deferred nested commits (depth > 1) do not touch the snapshot.
Constructors
EfUnitOfWork(TContext)
Initializes a new instance of EfUnitOfWork<TContext>.
public EfUnitOfWork(TContext context)
Parameters
contextTContextThe scoped DbContext to commit through.
Exceptions
- ArgumentNullException
Thrown when
contextis null.
Properties
CommittedAggregates
Gets the aggregates the unit of work tracked at its most recent successful commit. Empty before any commit has run, after a failed commit, or after a deferred nested commit.
public IReadOnlyList<IAggregate> CommittedAggregates { get; }
Property Value
Methods
BeginScope()
Begins a unit-of-work scope so that CommitAsync(CancellationToken) calls inside nested scopes
defer until only the outermost scope remains. The Trellis pipeline's
TransactionalCommandBehavior wraps every command in a scope so that a successful
inner command does not commit a partially-completed outer command's staged changes.
public IUnitOfWorkScope BeginScope()
Returns
- IUnitOfWorkScope
A scope whose Trellis.IUnitOfWorkScope.IsOwner identifies the outermost boundary. Dispose ends the scope by decrementing the depth counter; disposal itself does not trigger a commit.
Remarks
Nested-command semantics. Within a nested command's pipeline, a successful inner
handler's CommitAsync(CancellationToken) call is a no-op (returns Ok()
without touching the store) because depth > 1. The actual write happens when the
**outer** TransactionalCommandBehavior calls CommitAsync(CancellationToken) at depth == 1
— that call is still inside the outermost using scope. Disposing the outermost scope
afterwards just decrements the counter to 0; it does **not** itself invoke
CommitAsync(CancellationToken). At commit time, both the outer and inner staged changes are
persisted atomically by the implementation.
Caveat. If the inner command returns a failure but the outer handler chooses to ignore it and returns success anyway, the outer's commit will persist any changes the inner staged before failing. The unit-of-work is shared across the scope; per-scope rollback of staged changes is not supported. Handlers that need to discard inner failures' staged work must detach the affected entities themselves.
Concurrency. The depth counter is per-Trellis.IUnitOfWork-instance — i.e.
per DI scope. Concurrent commands sent on the **same** scoped Trellis.IUnitOfWork
(e.g. via Task.WhenAll(mediator.Send(a), mediator.Send(b)) from inside a handler)
are **not supported**: their scopes share the counter and one command's commit can be
suppressed by the other's open scope, or vice versa. Many stores (an EF Core
DbContext, for example) are not thread-safe regardless, so concurrent dispatch on a
single request scope is unsafe. To run commands in parallel, give each one its own DI scope
(e.g. IServiceScopeFactory) so each resolves its own Trellis.IUnitOfWork.
CommitAsync(CancellationToken)
Persists all staged changes to the backing store. Returns Result<TValue> with Unit to surface concurrency, duplicate-key, and foreign-key errors as Error instead of exceptions.
public Task<Result<Unit>> CommitAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationTokenA token to observe while waiting for the task to complete.
Returns
- Task<Result<Unit>>
A Result<TValue> with Unit representing success or failure.
Remarks
When called inside a nested Trellis.IUnitOfWork.BeginScope() scope (i.e. depth > 1), implementations should defer the actual write and return success without touching the store; only the outermost scope's CommitAsync(CancellationToken) call should persist staged changes. This prevents a successful inner command from committing a partially-completed outer command's staged changes.