Table of Contents

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

TContext

The 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

context TContext

The scoped DbContext to commit through.

Exceptions

ArgumentNullException

Thrown when context is 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

IReadOnlyList<IAggregate>

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

cancellationToken CancellationToken

A 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.