The Unit of Work Pattern in ASP.NET Core: When to Use It and How

You're building an enterprise ASP.NET Core API and someone on the team raises the question: "Should we implement a proper Unit of Work layer, or just inject DbContext directly?" It sounds like a minor architectural call, but the answer shapes how your transaction boundaries, testing strategy, and Clean Architecture layers hold up six months โ and six engineers โ later. The unit of work pattern in ASP.NET Core is one of those decisions where the right answer genuinely depends on what your system is doing. The trade-offs, edge cases, and integration details that tutorials skip are covered in depth on Patreon, alongside production-ready source code that shows the pattern working inside a real ASP.NET Core API you can run immediately.
Getting the theory is one thing. Seeing the Unit of Work wired into a full production codebase โ alongside repository interfaces, EF Core infrastructure implementations, and CQRS command handlers โ is another. That is exactly what Chapters 3 and 11 of the Zero to Production course cover, with source code you can open and adapt from day one.
EF Core's DbContext Is Already a Unit of Work โ So Why Add One?
This is the question that makes the conversation worth having. EF Core's DbContext already tracks every entity change you make within a request and commits or rolls back those changes atomically when you call SaveChangesAsync(). In Martin Fowler's original definition, that is precisely what a Unit of Work does: it keeps track of everything you've done during a business transaction and figures out everything that needs to be done to alter the database as a result.
So when developers add an explicit IUnitOfWork interface on top of DbContext, they're layering another abstraction over something that is already an abstraction. That can be justified โ but it needs to be justified deliberately, not by habit or by cargo-culting the pattern from a tutorial written in 2018.
For simpler systems โ single-context CRUD operations, microservices with a narrow domain, or APIs that keep data access tightly scoped per request โ the bare DbContext is the right tool. The Repository Pattern vs Direct DbContext in ASP.NET Core post on this blog is a good starting point if you're working through the full data access layer decision.
What an Explicit Unit of Work Interface Actually Provides
When teams add IUnitOfWork explicitly, they are typically solving for one or more specific technical concerns.
A single commit point across multiple repositories. In a DDD or Clean Architecture codebase, multiple repository implementations may be invoked within a single command handler. Without an explicit unit of work, each repository risks a separate SaveChangesAsync() call, opening the door to partial commits when a later operation fails. An explicit IUnitOfWork provides one centralized commit location for the entire business operation.
Domain and Application layer purity. In strict Clean Architecture, the Domain and Application layers must be entirely free of infrastructure dependencies. An IUnitOfWork interface can live in the Application layer, with its EF Core implementation sitting in Infrastructure โ the same layer as DbContext. This preserves the dependency rule and keeps Application handlers testable with a lightweight mock.
Explicit transaction semantics. Some workflows demand direct transaction control: coordinating writes across multiple aggregates, conditionally rolling back based on business rules, or managing DbContextFactory-created instances inside background services. An IUnitOfWork that exposes a begin-transaction operation makes those semantics visible in the application code rather than buried in infrastructure.
Testability without an EF Core dependency. Swapping an IUnitOfWork mock in unit tests is significantly simpler than mocking DbSet<T> and every DbContext method your handler touches. For teams running heavy handler-level unit test suites, the interface boundary meaningfully reduces test setup friction and makes tests easier to read.
When the Unit of Work Pattern Fits in ASP.NET Core Enterprise Systems
The pattern earns its place most clearly in four recurring scenarios.
You're using Clean Architecture or tactical DDD. If your codebase separates Domain, Application, Infrastructure, and API layers, and uses repository interfaces in the Application layer, IUnitOfWork sits alongside those interfaces naturally. It wraps the commit semantics without pushing EF Core specifics into the wrong layer.
A single business operation spans multiple repositories. When a command handler writes to more than one aggregate โ or multiple DbSet<T> entities through separate repository implementations โ you need a single commit point. Without it, a failure in a subsequent repository operation leaves your system in an inconsistent state with no clean rollback path.
You're working with IDbContextFactory in background services. BackgroundService runs as a singleton, and injecting DbContext directly causes a scoped service lifetime conflict. When creating and managing DbContext instances manually through IDbContextFactory, an explicit unit of work interface formalises the per-operation lifetime boundary so the rest of your application code does not need to reason about context lifetimes directly.
Your test strategy relies on handler-level unit tests. If your team unit tests command and query handlers in isolation โ without spinning up a real or in-memory database โ an IUnitOfWork mock is far more ergonomic than a DbContext mock. It keeps test setup lean and clearly signals which handlers commit changes as part of their operation.
When It Doesn't Fit: Three Signals You Should Skip It
The pattern is not a default โ it's a solution to specific problems. These signals suggest you don't have those problems.
You're building a microservice with a narrow domain. A service that manages one small aggregate through direct DbContext injection and a few repository methods gains nothing from IUnitOfWork. The abstraction introduces a layer developers must navigate without solving a real problem.
Your team is conflating Unit of Work with Repository. A common failure mode is building an IUnitOfWork that exposes repositories as properties โ _unitOfWork.Orders.Add(order), _unitOfWork.Customers.FindByIdAsync(id). This turns the unit of work into a service locator and mixes two separate concerns. It is almost always a sign that neither the Unit of Work nor the Repository boundary is correctly understood.
Every request writes to a single aggregate through a single repository. If your pattern of data access is one repository, one aggregate, one SaveChangesAsync() per request โ the implicit unit of work in DbContext already covers you completely. Adding IUnitOfWork here is overhead that future developers will spend time understanding before concluding it adds nothing.
Architectural Placement: Where Does IUnitOfWork Belong?
The interface belongs in the Application layer โ not Domain, and not Infrastructure. Domain should remain entirely free of infrastructure concerns. Application is the natural home: it orchestrates use cases, knows about repository interfaces and the commit contract, but holds no direct EF Core dependency.
The implementation โ the class that wraps DbContext and calls SaveChangesAsync() โ belongs in Infrastructure, alongside migrations, EF Core configurations, and repository implementations.
This separation is what makes the pattern testable. Application layer tests inject a no-op or mock IUnitOfWork without standing up a database. Infrastructure integration tests exercise the real EF Core implementation against a test database. Microsoft's architecture documentation for DDD persistence patterns explicitly models this interface boundary, treating IUnitOfWork as the Application layer's contract with the persistence subsystem.
If you're using CQRS alongside this layer setup, the CQRS and MediatR in ASP.NET Core: Enterprise Decision Guide explains how to keep transaction semantics clean when MediatR pipeline behaviors and unit of work calls coexist in the same command pipeline.
Key Trade-offs Teams Consistently Miss
Indirection cost. Every abstraction is a layer developers must traverse to understand what code actually runs. Document why IUnitOfWork exists. Without context, junior engineers will assume it's a boilerplate requirement and copy the pattern into every new project regardless of fit.
Overlap with MediatR transaction behaviors. If you're using a MediatR TransactionBehavior pipeline behavior to wrap each command in a database transaction, you may create conflicting or nested transaction scopes if command handlers also call commit explicitly. Decide where transaction control lives and enforce it consistently โ don't let both mechanisms compete.
Async disposal. EF Core's DbContext implements IAsyncDisposable. If your IUnitOfWork wraps DbContext, disposal must be handled correctly โ especially in background service scenarios where lifetime management is manual rather than DI-scoped.
Keep the interface minimal. As aggregate count and team size grow, the IUnitOfWork interface becomes a shared contract. Changing its shape requires coordinated updates across all consumers. Expose only what you need โ SaveChangesAsync() with a CancellationToken, and transaction control if your use cases require it. Resist expanding it into a repository registry.
Decision Matrix
| Situation | Recommendation |
|---|---|
| Single context, simple CRUD API | Skip IUnitOfWork โ bare DbContext is sufficient |
| Multi-repository single business transaction | Use IUnitOfWork for a clean, atomic commit point |
| Clean Architecture / DDD layering | Use IUnitOfWork in the Application layer |
| Background service using IDbContextFactory | Use IUnitOfWork to formalise context lifetime |
| Microservice with a narrow domain | Skip โ adds abstraction without solving a problem |
| Heavy unit test suite on command handlers | Use IUnitOfWork for mock-friendly test boundaries |
Anti-Patterns to Avoid
The God Unit of Work. An IUnitOfWork that exposes every repository as a property becomes a dependency dumping ground. Repositories should be injected independently. Keep IUnitOfWork as a pure commit contract.
Committing inside repositories. If your repository implementations call SaveChangesAsync() themselves, you have broken the unit of work boundary. Repositories are responsible for tracking changes. The unit of work is responsible for committing them. Mixing these responsibilities makes multi-step transactions unreliable and hard to reason about.
Wrapping the Unit of Work around non-EF data access. The EF Core-backed unit of work only covers entities tracked by DbContext. Dapper queries and raw SQL commands operate outside EF Core's change tracking. If parts of your data access bypass EF Core, the unit of work boundary does not cover those operations. Use explicit DbTransaction objects to span both, or treat that boundary as a known seam in your transaction model.
Frequently Asked Questions
Does EF Core's DbContext already implement the Unit of Work pattern? Yes. DbContext tracks all entity changes within the same instance and commits them atomically on SaveChangesAsync(). For many applications, that implicit unit of work is fully sufficient. An explicit IUnitOfWork wrapper adds value primarily in Clean Architecture codebases where the Application layer must not reference EF Core directly, or in scenarios where a single business operation spans multiple repository calls.
Should IUnitOfWork expose repository properties? No. An IUnitOfWork that exposes repositories as properties evolves into a service locator, conflating two distinct concerns. Keep IUnitOfWork focused on the commit contract and inject repositories through the DI container separately.
Where should the IUnitOfWork interface be defined in Clean Architecture? In the Application layer. The interface lives where it is consumed โ inside command and query handlers. The EF Core implementation lives in the Infrastructure layer. This placement preserves the dependency rule and keeps Application completely free of direct EF Core dependencies.
Is the Unit of Work pattern still relevant in microservices? In genuinely small microservices with a narrow domain, the pattern often adds complexity without clear benefit. It becomes more relevant as aggregate count grows, as multiple repositories are needed within a single command, or when the Clean Architecture layer boundary is enforced for long-term testability.
Can the Unit of Work pattern work with Dapper or raw SQL? Not natively. The EF Core-backed unit of work only covers entities tracked by DbContext. For cross-technology transaction boundaries, you need to share a DbConnection and DbTransaction explicitly rather than relying on a DbContext-based unit of work.
What is the difference between Unit of Work and a database transaction? A database transaction is a low-level mechanism guaranteeing atomicity, consistency, isolation, and durability (ACID). A Unit of Work is an application-level pattern that coordinates multiple operations before issuing a single commit. The Unit of Work typically uses a transaction internally, but it also tracks entity changes, manages identity maps, and provides a commit abstraction at the business operation level rather than the infrastructure level.
Should I use IUnitOfWork with MediatR pipeline behaviors? Yes, but be deliberate about where commit control lives. A common approach is a MediatR TransactionBehavior that begins a database transaction before the command runs and commits after the handler completes. In that model, command handlers do not call SaveChangesAsync() directly โ the pipeline behavior owns commit semantics. Mixing both approaches risks nested transactions or silent double-commits. Choose one approach and apply it uniformly.





