The Unit of Work pattern wraps multiple repository operations in a shared database session, ensuring they all commit or roll back together. In EF Core, the DbContext already acts as a Unit of Work — all repositories using the same DbContext instance share its change tracker and single SaveChangesAsync() call. The explicit Unit of Work pattern surfaces this coordination in the service layer: instead of each repository calling SaveChangesAsync() internally, they only track changes, and the Unit of Work calls SaveChangesAsync() once at the end.
Unit of Work Implementation
// ── IUnitOfWork interface ──────────────────────────────────────────────────
public interface IUnitOfWork : IDisposable
{
IPostRepository Posts { get; }
ICommentRepository Comments { get; }
ITagRepository Tags { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
// ── EF Core implementation ─────────────────────────────────────────────────
public class UnitOfWork(AppDbContext db) : IUnitOfWork
{
// Lazy-initialised repositories all sharing the same DbContext instance
private IPostRepository? _posts;
private ICommentRepository? _comments;
private ITagRepository? _tags;
public IPostRepository Posts => _posts ??= new PostRepository(db);
public ICommentRepository Comments => _comments ??= new CommentRepository(db);
public ITagRepository Tags => _tags ??= new TagRepository(db);
// ONE SaveChangesAsync for all repositories — atomic commit
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> db.SaveChangesAsync(ct);
public void Dispose() => db.Dispose();
}
// ── Registration ──────────────────────────────────────────────────────────
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
// Note: repositories are now created by UnitOfWork, not individually registered
// ── Service using UnitOfWork — atomic multi-entity operation ──────────────
public class PostService(IUnitOfWork uow, ILogger<PostService> logger) : IPostService
{
public async Task<PostDto> CreateWithTagsAsync(
CreatePostRequest request, string authorId, CancellationToken ct)
{
// Operation 1: Create the post
var post = Post.Create(request.Title, request.Slug, request.Body, authorId);
await uow.Posts.AddAsync(post, ct); // tracked, not yet saved
// Operation 2: Ensure tags exist and link them
foreach (var tagName in request.Tags)
{
var tag = await uow.Tags.GetByNameAsync(tagName, ct)
?? await uow.Tags.AddAsync(Tag.Create(tagName), ct);
post.AddTag(tag);
}
// ONE SaveChangesAsync — both post and tags committed atomically
await uow.SaveChangesAsync(ct);
logger.LogInformation("Post {Id} created with {Count} tags.", post.Id, request.Tags.Count);
return post.ToDto();
}
}
SaveChangesAsync() internally. Repositories only call db.Posts.Add(entity), db.Posts.Remove(entity), etc. — they track changes but do not commit them. The Unit of Work is the only caller of SaveChangesAsync(). If a repository calls SaveChangesAsync() internally, the other repository’s changes may or may not be included depending on execution order — breaking the atomicity guarantee.DbContext is already a Unit of Work. If you inject AppDbContext directly into multiple service classes in the same request, they already share the same instance (Scoped) and changes from all services are committed in one SaveChangesAsync() call. The explicit Unit of Work pattern adds a named abstraction over this behaviour — whether the added ceremony is worth it depends on your team size, architecture strictness, and whether you need to swap the UoW implementation in tests.SaveChangesAsync() in the middle of a multi-step service operation unless you explicitly want partial commits. Partial commits can leave data in an inconsistent state if subsequent steps fail. The Unit of Work should call SaveChangesAsync() exactly once at the end of the logical operation — either all changes commit together or none do. If an exception occurs before the final SaveChangesAsync(), all tracked changes are discarded when the DbContext scope ends.When Unit of Work Adds Value
| Scenario | Value of UoW |
|---|---|
| Single entity CRUD | Low — DbContext already handles this |
| Multi-entity atomic operation | High — ensures all or nothing |
| Testing service layer without database | High — mock IUnitOfWork in unit tests |
| Small single-developer project | Low — adds complexity without proportional benefit |
| Large team with clear layer boundaries | High — enforces architecture |
Common Mistakes
Mistake 1 — Repository calling SaveChangesAsync() when using Unit of Work (breaks atomicity)
❌ Wrong — PostRepository.AddAsync() calls SaveChangesAsync(); TagRepository.AddAsync() calls SaveChangesAsync(); two separate commits, not one atomic operation.
✅ Correct — repositories only track changes; only UnitOfWork.SaveChangesAsync() commits.
Mistake 2 — Registering repositories AND Unit of Work separately (two DbContext instances per request)
❌ Wrong — IPostRepository and IUnitOfWork both registered; each creates its own DbContext; changes in one are not visible to the other.
✅ Correct — when using UoW, let it own all repositories; do not separately register IPostRepository.