Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

17KB

Skill: Unit of Work Pattern for C# and .NET

Use this skill when implementing, reviewing, or refactoring data access that uses the Unit of Work pattern with C#, .NET, Entity Framework Core, repositories, services, or ASP.NET Core controllers/endpoints.

Purpose

The Unit of Work pattern coordinates changes across one or more repositories so a logical business operation is committed as a single transaction boundary.

Use it to:

  • keep one shared DbContext per logical operation or web request;
  • group related inserts, updates, and deletes before saving;
  • expose repositories through a single abstraction;
  • prevent scattered SaveChangesAsync calls throughout controllers and repositories;
  • make transaction boundaries easier to reason about and test.

Do not add this pattern automatically to every EF Core project. EF Core DbContext already behaves like a Unit of Work and each DbSet<TEntity> behaves like a repository. Add a custom Unit of Work only when the project needs a consistent repository abstraction, multi-repository coordination, test seams, or a team convention that already uses it.

When to Load This Skill

Load this skill when the task mentions any of these concepts:

  • Unit of Work
  • repository pattern
  • generic repository
  • EF Core repository
  • IUnitOfWork
  • UnitOfWork
  • IGenericRepository<T>
  • GenericRepository<T>
  • transaction boundary
  • SaveChangesAsync
  • adding a repository for a new entity
  • refactoring database access out of controllers
  • coordinating multiple database changes

Also load:

.ai/skills/10-ef-core-data-access.md
.ai/skills/11-linq-querying.md
.ai/skills/12-aspnetcore-web-fundamentals.md
.ai/skills/17-code-review-definition-of-done.md

Core Rules

1. Keep one DbContext per Unit of Work

All repositories exposed by a Unit of Work must share the same injected EF Core context instance.

public sealed class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
    }
}

Do not create a new DbContext inside individual repository properties. That breaks the shared transaction/change-tracking boundary.

2. Register Unit of Work as scoped; use IServiceScopeFactory for per-operation work in desktop apps

EF Core contexts are normally scoped per request. Register the Unit of Work with the same lifetime.

ASP.NET Core (per-request scope managed by the framework):

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

WinForms / WPF / console (per-operation scope managed manually):

services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
services.AddScoped<IUnitOfWork, UnitOfWork>();

// In a form or service, inject IServiceScopeFactory and create one scope per operation:
private async Task SaveAsync()
{
    using var scope = _scopeFactory.CreateScope();
    var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
    await uow.Orders.AddAsync(order);
    await uow.SaveChangesAsync();
}

This keeps one fresh DbContext per logical operation and avoids stale change-tracking across user actions.

Avoid Singleton for DbContext, repositories, or Unit of Work. Prefer Scoped for web apps. Transient can work in simple cases, but Scoped is the safer default when the Unit of Work wraps a scoped DbContext.

3. Repositories stage changes; Unit of Work saves changes

Repository methods should add, update, remove, or query entities. The Unit of Work should commit.

Good:

await unitOfWork.Customers.AddAsync(customer, cancellationToken);
await unitOfWork.Orders.AddAsync(order, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

Avoid:

await repository.AddAsync(entity);
await repository.SaveChangesAsync(); // Avoid per-repository saves.

Calling SaveChangesAsync inside every repository method weakens the pattern because each repository operation becomes its own transaction boundary.

4. Expose repositories by aggregate or entity

Use clear repository properties on IUnitOfWork.

public interface IUnitOfWork
{
    IGenericRepository<Customer> Customers { get; }
    IGenericRepository<Order> Orders { get; }
    IGenericRepository<Product> Products { get; }

    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

For domain-driven designs, prefer repositories per aggregate root instead of every table. For CRUD-style apps, entity-level generic repositories may be acceptable.

5. Lazily initialize repository properties

Create each repository once per Unit of Work instance.

private IGenericRepository<Customer>? _customers;

public IGenericRepository<Customer> Customers =>
    _customers ??= new GenericRepository<Customer>(_context);

This keeps repository construction cheap and preserves the shared context.

6. Use async APIs and cancellation tokens

Prefer async EF Core methods and pass CancellationToken through controllers, services, repositories, and Unit of Work methods.

public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    return _context.SaveChangesAsync(cancellationToken);
}

Use AddAsync, ToListAsync, FirstOrDefaultAsync, SingleOrDefaultAsync, and SaveChangesAsync where applicable.

Generic repository

public interface IGenericRepository<TEntity> where TEntity : class
{
    Task<IReadOnlyList<TEntity>> GetAllAsync(
        Expression<Func<TEntity, bool>>? filter = null,
        Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
        Func<IQueryable<TEntity>, IQueryable<TEntity>>? include = null,
        bool asNoTracking = true,
        CancellationToken cancellationToken = default);

    Task<TEntity?> GetAsync(
        Expression<Func<TEntity, bool>> filter,
        Func<IQueryable<TEntity>, IQueryable<TEntity>>? include = null,
        bool asNoTracking = true,
        CancellationToken cancellationToken = default);

    Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);
    Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);
    void Update(TEntity entity);
    void Remove(TEntity entity);
    void RemoveRange(IEnumerable<TEntity> entities);
}

Unit of Work

public interface IUnitOfWork
{
    IGenericRepository<Customer> Customers { get; }
    IGenericRepository<Order> Orders { get; }

    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

Do not extend IUnitOfWork with IAsyncDisposable unless the Unit of Work explicitly owns the context and you also implement IDisposable. If IUnitOfWork only implements IAsyncDisposable, the DI container will throw InvalidOperationException when it tries to synchronously dispose a scope — which happens in WinForms, WPF, console apps, and unit tests. In ASP.NET Core, the container disposes the scoped DbContext automatically; the Unit of Work does not need to manage context disposal at all.

Generic repository implementation

public class GenericRepository<TEntity> : IGenericRepository<TEntity>
    where TEntity : class
{
    protected readonly AppDbContext Context;
    protected readonly DbSet<TEntity> DbSet;

    public GenericRepository(AppDbContext context)
    {
        Context = context;
        DbSet = context.Set<TEntity>();
    }

    public async Task<IReadOnlyList<TEntity>> GetAllAsync(
        Expression<Func<TEntity, bool>>? filter = null,
        Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
        Func<IQueryable<TEntity>, IQueryable<TEntity>>? include = null,
        bool asNoTracking = true,
        CancellationToken cancellationToken = default)
    {
        IQueryable<TEntity> query = DbSet;

        if (asNoTracking)
        {
            query = query.AsNoTracking();
        }

        if (filter is not null)
        {
            query = query.Where(filter);
        }

        if (include is not null)
        {
            query = include(query);
        }

        if (orderBy is not null)
        {
            query = orderBy(query);
        }

        return await query.ToListAsync(cancellationToken);
    }

    public async Task<TEntity?> GetAsync(
        Expression<Func<TEntity, bool>> filter,
        Func<IQueryable<TEntity>, IQueryable<TEntity>>? include = null,
        bool asNoTracking = true,
        CancellationToken cancellationToken = default)
    {
        IQueryable<TEntity> query = DbSet;

        if (asNoTracking)
        {
            query = query.AsNoTracking();
        }

        if (include is not null)
        {
            query = include(query);
        }

        return await query.FirstOrDefaultAsync(filter, cancellationToken);
    }

    public Task AddAsync(TEntity entity, CancellationToken cancellationToken = default)
    {
        return DbSet.AddAsync(entity, cancellationToken).AsTask();
    }

    public Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
    {
        return DbSet.AddRangeAsync(entities, cancellationToken);
    }

    public void Update(TEntity entity)
    {
        DbSet.Update(entity);
    }

    public void Remove(TEntity entity)
    {
        DbSet.Remove(entity);
    }

    public void RemoveRange(IEnumerable<TEntity> entities)
    {
        DbSet.RemoveRange(entities);
    }
}

Unit of Work implementation

public sealed class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    private IGenericRepository<Customer>? _customers;
    private IGenericRepository<Order>? _orders;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
    }

    public IGenericRepository<Customer> Customers =>
        _customers ??= new GenericRepository<Customer>(_context);

    public IGenericRepository<Order> Orders =>
        _orders ??= new GenericRepository<Order>(_context);

    public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        return _context.SaveChangesAsync(cancellationToken);
    }
}

The DI container disposes the scoped DbContext when the scope ends. Do not add a DisposeAsync or Dispose method to UnitOfWork unless the project explicitly creates and owns the context outside of DI. If you do add disposal, implement both IDisposable and IAsyncDisposable — implementing only IAsyncDisposable causes the container to throw when disposing a scope synchronously (for example in WinForms, WPF, console, or test code that uses using var scope).

Query Rules

Prefer typed includes over string includes

Good:

var orders = await unitOfWork.Orders.GetAllAsync(
    include: query => query.Include(order => order.Customer)
                           .Include(order => order.OrderLines),
    cancellationToken: cancellationToken);

Avoid string includes unless the existing project style already uses them or dynamic includes are required.

// Less refactor-safe:
query = query.Include("Customer");

Use AsNoTracking for read-only queries

Use no-tracking queries for list pages, reports, dropdowns, read-only API responses, and other cases where the entity will not be edited in the same context.

Do not use AsNoTracking when you plan to modify the returned entity and rely on change tracking.

Return nullable results when records might not exist

Use TEntity? for lookup methods that might not find a record.

Task<TEntity?> GetAsync(Expression<Func<TEntity, bool>> filter, ...);

Then handle not-found cases explicitly in services/controllers.

Controller and Service Usage

Prefer using Unit of Work from application services. Controllers/endpoints should be thin.

public sealed class OrderService
{
    private readonly IUnitOfWork _unitOfWork;

    public OrderService(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<int> CreateOrderAsync(CreateOrderRequest request, CancellationToken cancellationToken)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            CreatedAtUtc = DateTime.UtcNow
        };

        await _unitOfWork.Orders.AddAsync(order, cancellationToken);
        return await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

Avoid injecting both AppDbContext and IUnitOfWork into the same service/controller unless there is a temporary migration reason. Mixed access makes transaction boundaries unclear.

Transactions

A single SaveChangesAsync call is already transactional for most relational EF Core providers. Use explicit transactions when a logical operation requires multiple SaveChangesAsync calls or coordination with non-EF operations.

await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);

await unitOfWork.Customers.AddAsync(customer, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

await externalAuditWriter.WriteAsync(customer.Id, cancellationToken);

await unitOfWork.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);

Keep explicit transactions in a service layer, not inside generic repository methods.

Validation and Error Handling

  • Validate input before adding entities to repositories.
  • Enforce business rules in services/domain methods, not in generic repositories.
  • Let EF Core exceptions bubble to a higher layer where they can be logged and converted into user-friendly responses.
  • Handle concurrency conflicts explicitly when using row versions or concurrency tokens.
  • Do not swallow DbUpdateException or DbUpdateConcurrencyException without logging and a clear recovery path.

Testing Rules

For Unit of Work code:

  • test service behavior at the Unit of Work boundary;
  • verify SaveChangesAsync is called once per logical operation when using mocks;
  • prefer SQLite in-memory or a test container over EF Core InMemory provider for relational behavior;
  • test includes, filters, ordering, and not-found paths;
  • test transactions and rollback behavior when business logic spans multiple changes.

Common Mistakes to Avoid

  • Creating a separate DbContext per repository.
  • Calling SaveChangesAsync inside every repository method.
  • Registering DbContext or Unit of Work as singleton.
  • Mixing raw DbContext access and Unit of Work access in the same service without a clear reason.
  • Returning IQueryable from repositories to upper layers without a deliberate design decision.
  • Hiding all EF Core capabilities behind a generic repository when a specific query/service would be clearer.
  • Using string includes when typed includes are available.
  • Ignoring cancellation tokens.
  • Treating Unit of Work as a substitute for business/domain services.
  • Implementing only IAsyncDisposable on UnitOfWork — the DI container throws InvalidOperationException when it synchronously disposes a scope (WinForms, WPF, console, tests). Either implement both IDisposable and IAsyncDisposable, or implement neither and let the container own DbContext disposal.

Adding a New Entity Repository

When adding a new entity to a Unit of Work:

  1. Add a DbSet<TEntity> to the EF Core context if it does not already exist.
  2. Add an IGenericRepository<TEntity> property to IUnitOfWork.
  3. Add a nullable backing field to UnitOfWork.
  4. Add a lazy property implementation using the shared context.
  5. Add service/controller usage through constructor-injected IUnitOfWork.
  6. Call SaveChangesAsync once at the end of each logical mutation.
  7. Add tests for query, create, update, delete, and not-found behavior.

Example:

// IUnitOfWork.cs
IGenericRepository<Invoice> Invoices { get; }

// UnitOfWork.cs
private IGenericRepository<Invoice>? _invoices;

public IGenericRepository<Invoice> Invoices =>
    _invoices ??= new GenericRepository<Invoice>(_context);

Definition of Done

A Unit of Work change is done only when:

  • all repositories share one scoped EF Core context;
  • dependency injection registration uses appropriate lifetimes;
  • mutation flows call SaveChangesAsync once per logical operation;
  • read-only queries use AsNoTracking where appropriate;
  • not-found results are handled explicitly;
  • includes are typed where possible;
  • cancellation tokens flow through async operations;
  • tests cover successful operations, validation failures, and not-found cases;
  • the code builds, tests pass, and no transaction boundary is ambiguous.

Powered by TurnKey Linux.