Unit of Work — Coordinating Multiple Repositories

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();
    }
}
Note: For the Unit of Work pattern to work correctly, each repository must not call 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.
Tip: EF Core’s 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.
Warning: Do not call 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.

🧠 Test Yourself

A service method creates a Post (via IUnitOfWork.Posts.AddAsync) and creates an AuditLog entry (via IUnitOfWork.Logs.AddAsync), then calls IUnitOfWork.SaveChangesAsync(). If the database throws an exception during the commit, what happens to both the Post and AuditLog?