I know what it is, but what does it mean?
When infrastructure leaks into your domain and how to restore the boundary
First, let us lay out the coordinates of the discussion. This is about the fairly common situation where data access is abstracted away:
behind a domain model (entities implemented as classes)
using an ORM that maps those entities to database artifacts (mostly tables but not limited to),
through repositories that serve those entities to the rest of the system, from controllers to domain or application services,
leveraging Entity Framework (so we’re talking about a
C#/.NETapplication).
Once Entity Framework is in the picture, you quickly run into the question of how to manage the lifetime of DbContext: specifically, how to share it across multiple repositories participating in what we consider “one” business transaction.
After trying a few approaches, I ended up adopting the pattern described in Mehdi El Gueddari’s brilliant piece about ambient DbContext scope (worth reading before continuing).
Let us present the same example as that post does: a UserService with a MarkUserAsPremium method (I won’t repeat the repository implementation here — it’s not the interesting part).
class UserService : IUserService
{
private IUserRepository _userRepository;
private IDbContextScopeFactory _dbContextScopeFactory;
public void MarkUserAsPremium(Guid userId)
{
using (IDbContextScope dbContextScope = _dbContextScopeFactory.Create())
{
User user = _userRepository.Get(userId);
user.IsPremiumUser = true;
dbContextScope.SaveChanges();
}
}
}The key takeaway is this: the IDbContextScope / IDbContextScopeFactory pair is, in practice, an implementation detail. It ultimately exists to support repository behavior across different actor boundaries, and repository code may rely on the presence of an ambient scope. But the service itself claims to deal in abstractions: entities, repositories, business operations. Basically, in a sense we are breaking the fourth wall and the abstractions along with it.
To restore coherence we must ask ourselves what does IDbContextScope really mean? To answer this we must first ask ourselves what does it do? Well:
it defines the boundary of an abstract business transaction
and the boundary of the corresponding unit of work against the database.
Once you see it that way, the next step becomes obvious: name the thing after what it means, not after how it’s implemented. Make the intent explicit, without forcing the rest of the code base to learn about a relatively minor implementation artifact.
Now we can introduce two interfaces that serve as a mild abstraction that keeps things within the ball park of our domain-related semantics:
IBusinessTransactionto explicitly convey the meaning currently carried byIDbContextScope;and
IBusinessTransactionCoordinatorto explicitly convey the meaning currently carried byIDbContextScopeFactory.
public interface IBusinessTransactionCoordinator
{
IBusinessTransaction BeginTransaction();
}
public interface IBusinessTransaction : IDisposable
{
void Commit();
Task CommitAsync();
Task CommitAsync(CancellationToken cancelToken);
void Rollback();
Task RollbackAsync();
}As you can imagine, the implementation is intentionally straightforward. First, IBusinessTransaction is merely a façade over IDbContextScope. It doesn’t create the scope; it merely ensures correct manipulation.
public class DbContextScopeBusinessTransaction : IBusinessTransaction
{
private bool mIsDisposed = false;
private IDbContextScope mDbContextScope;
public DbContextScopeBusinessTransaction( IDbContextScope dbContextScope )
{
mDbContextScope = dbContextScope
?? throw new ArgumentNullException( nameof( dbContextScope ) );
}
private void EnsureNotDisposedOrThrow()
{
if (mIsDisposed)
throw new ObjectDisposedException( nameof( DbContextScopeBusinessTransaction ) );
}
public void Commit()
{
EnsureNotDisposedOrThrow();
mDbContextScope.SaveChanges();
}
public async Task CommitAsync()
{
EnsureNotDisposedOrThrow();
await mDbContextScope.SaveChangesAsync().ConfigureAwait( false );
}
public async Task CommitAsync( CancellationToken cancelToken )
{
EnsureNotDisposedOrThrow();
await mDbContextScope.SaveChangesAsync( cancelToken ).ConfigureAwait( false );
}
public void Rollback()
{
EnsureNotDisposedOrThrow();
Dispose();
}
public Task RollbackAsync()
{
EnsureNotDisposedOrThrow();
Dispose();
return Task.CompletedTask;
}
protected void Dispose( bool disposing )
{
if (!mIsDisposed)
{
if (disposing)
{
mDbContextScope.Dispose();
mDbContextScope = null;
}
mIsDisposed = true;
}
}
public void Dispose()
{
Dispose( true );
GC.SuppressFinalize( this );
}
}In this implementation, although no explicit rollback operation is provided by the underlying IDbContextScope, we simply dispose the scope without calling Commit(), which mirrors the behavior of DbTransaction in ADO.NET.
Second, IBusinessTransactionCoordinator simply creates that façade, using an IDbContextScopeFactory to manufacture the underlying scope. It could take on additional responsibilities later, but for our purposes this is enough:
public class DbContextScopeBusinessTransactionCoordinator : IBusinessTransactionCoordinator
{
private IDbContextScopeFactory mDbContextScopeFactory;
public DbContextScopeBusinessTransactionCoordinator( IDbContextScopeFactory dbContextScopeFactory )
{
mDbContextScopeFactory = dbContextScopeFactory
?? throw new ArgumentNullException( nameof( dbContextScopeFactory ) );
}
public IBusinessTransaction BeginTransaction()
{
IDbContextScope dbContextScope = mDbContextScopeFactory.Create();
return new DbContextScopeBusinessTransaction( dbContextScope );
}
}Their usage stays the same as the artifacts they abstract away. It even becomes simpler to read because the code now says what it means. And it allows us to avoid referencing that assembly in our domain code base.
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
private readonly IBusinessTransactionCoordinator _txCoordinator;
//...
public void MarkUserAsPremium(Guid userId)
{
using (IBusinessTransaction tx = _txCoordinator.BeginTransaction())
{
User user = _userRepository.Get(userId);
user.IsPremiumUser = true;
tx.Commit();
}
}
}Nothing magical happened. The data access pattern is the same. The behavior is the same. What changed is the story the code tells: the service executes a business transaction and commits it. The infrastructure may still rely on ambient DbContext scope, but the service (or nobody else for that matter) doesn’t need to know or care.
These are also small enough to carry them around in your back pocket. For instance, what I usually do is:
create a
MyProject.ModelAPIproject into which I implement common primitives in support of domain-related entities and other actors such as domain services and for me it’s the ideal place to host these two interface;create a
MyProject.ModelInfrastructureproject into which I usually place everything related to common domain infrastructure concerns, including the interface implementations laid out here.
As you can imagine, this approach allows for easily mocking out the transaction behavior, as well as swapping the unit of work management later on, thus ensuring that the cognitive cost of an additional abstraction is justified by the clarity it brings.
Naming an infrastructure concern after what it truly represents may seem like a small adjustment. And it is. But small semantic shifts accumulate.
Once you start treating boundaries seriously — transactions, units of work, domain concepts — you inevitably begin asking a larger question: how much of our code is describing intent, and how much is merely plumbing?



