# 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` 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` - `GenericRepository` - transaction boundary - `SaveChangesAsync` - adding a repository for a new entity - refactoring database access out of controllers - coordinating multiple database changes Also load: ```text .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. ```csharp 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 in ASP.NET Core apps EF Core contexts are normally scoped per request. Register the Unit of Work with the same lifetime. ```csharp builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddScoped(); ``` 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: ```csharp await unitOfWork.Customers.AddAsync(customer, cancellationToken); await unitOfWork.Orders.AddAsync(order, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); ``` Avoid: ```csharp 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`. ```csharp public interface IUnitOfWork { IGenericRepository Customers { get; } IGenericRepository Orders { get; } IGenericRepository Products { get; } Task 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. ```csharp private IGenericRepository? _customers; public IGenericRepository Customers => _customers ??= new GenericRepository(_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. ```csharp public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return _context.SaveChangesAsync(cancellationToken); } ``` Use `AddAsync`, `ToListAsync`, `FirstOrDefaultAsync`, `SingleOrDefaultAsync`, and `SaveChangesAsync` where applicable. ## Recommended Interfaces ### Generic repository ```csharp public interface IGenericRepository where TEntity : class { Task> GetAllAsync( Expression>? filter = null, Func, IOrderedQueryable>? orderBy = null, Func, IQueryable>? include = null, bool asNoTracking = true, CancellationToken cancellationToken = default); Task GetAsync( Expression> filter, Func, IQueryable>? include = null, bool asNoTracking = true, CancellationToken cancellationToken = default); Task AddAsync(TEntity entity, CancellationToken cancellationToken = default); Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); void Update(TEntity entity); void Remove(TEntity entity); void RemoveRange(IEnumerable entities); } ``` ### Unit of Work ```csharp public interface IUnitOfWork : IAsyncDisposable { IGenericRepository Customers { get; } IGenericRepository Orders { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); } ``` Use `IAsyncDisposable` when the Unit of Work owns the context disposal. In ASP.NET Core dependency injection, the container usually disposes the scoped context. In that case, implementing disposal is optional unless the project convention requires it. ## Recommended Implementation ### Generic repository implementation ```csharp public class GenericRepository : IGenericRepository where TEntity : class { protected readonly AppDbContext Context; protected readonly DbSet DbSet; public GenericRepository(AppDbContext context) { Context = context; DbSet = context.Set(); } public async Task> GetAllAsync( Expression>? filter = null, Func, IOrderedQueryable>? orderBy = null, Func, IQueryable>? include = null, bool asNoTracking = true, CancellationToken cancellationToken = default) { IQueryable 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 GetAsync( Expression> filter, Func, IQueryable>? include = null, bool asNoTracking = true, CancellationToken cancellationToken = default) { IQueryable 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 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 entities) { DbSet.RemoveRange(entities); } } ``` ### Unit of Work implementation ```csharp public sealed class UnitOfWork : IUnitOfWork { private readonly AppDbContext _context; private IGenericRepository? _customers; private IGenericRepository? _orders; public UnitOfWork(AppDbContext context) { _context = context; } public IGenericRepository Customers => _customers ??= new GenericRepository(_context); public IGenericRepository Orders => _orders ??= new GenericRepository(_context); public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return _context.SaveChangesAsync(cancellationToken); } public ValueTask DisposeAsync() { return _context.DisposeAsync(); } } ``` If ASP.NET Core dependency injection owns the `DbContext`, do not manually dispose it inside request code. Let the container manage scoped disposal. ## Query Rules ### Prefer typed includes over string includes Good: ```csharp 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. ```csharp // 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. ```csharp Task GetAsync(Expression> 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. ```csharp public sealed class OrderService { private readonly IUnitOfWork _unitOfWork; public OrderService(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public async Task 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. ```csharp 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. ## Adding a New Entity Repository When adding a new entity to a Unit of Work: 1. Add a `DbSet` to the EF Core context if it does not already exist. 2. Add an `IGenericRepository` 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: ```csharp // IUnitOfWork.cs IGenericRepository Invoices { get; } // UnitOfWork.cs private IGenericRepository? _invoices; public IGenericRepository Invoices => _invoices ??= new GenericRepository(_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.