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.
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:
DbContext per logical operation or web request;SaveChangesAsync calls throughout controllers and repositories;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.
Load this skill when the task mentions any of these concepts:
IUnitOfWorkUnitOfWorkIGenericRepository<T>GenericRepository<T>SaveChangesAsyncAlso 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
DbContext per Unit of WorkAll 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.
EF Core contexts are normally scoped per request. Register the Unit of Work with the same lifetime.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
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.
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.
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.
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.
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.
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);
}
public interface IUnitOfWork : IAsyncDisposable
{
IGenericRepository<Customer> Customers { get; }
IGenericRepository<Order> Orders { get; }
Task<int> 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.
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);
}
}
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);
}
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.
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");
AsNoTracking for read-only queriesUse 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.
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.
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.
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.
DbUpdateException or DbUpdateConcurrencyException without logging and a clear recovery path.For Unit of Work code:
SaveChangesAsync is called once per logical operation when using mocks;DbContext per repository.SaveChangesAsync inside every repository method.DbContext or Unit of Work as singleton.DbContext access and Unit of Work access in the same service without a clear reason.IQueryable from repositories to upper layers without a deliberate design decision.When adding a new entity to a Unit of Work:
DbSet<TEntity> to the EF Core context if it does not already exist.IGenericRepository<TEntity> property to IUnitOfWork.UnitOfWork.IUnitOfWork.SaveChangesAsync once at the end of each logical mutation.Example:
// IUnitOfWork.cs
IGenericRepository<Invoice> Invoices { get; }
// UnitOfWork.cs
private IGenericRepository<Invoice>? _invoices;
public IGenericRepository<Invoice> Invoices =>
_invoices ??= new GenericRepository<Invoice>(_context);
A Unit of Work change is done only when:
SaveChangesAsync once per logical operation;AsNoTracking where appropriate;Powered by TurnKey Linux.