# 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; 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):** ```csharp builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddScoped(); ``` **WinForms / WPF / console (per-operation scope managed manually):** ```csharp services.AddDbContext(options => options.UseSqlite(connectionString)); services.AddScoped(); // 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(); 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: ```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 { IGenericRepository Customers { get; } IGenericRepository Orders { get; } Task 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. ## 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); } } ``` 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: ```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. - 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` 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.