Overview
Repository, Unit of Work, Mediator, and Specification are common design patterns used in .NET applications to organize business logic, persistence logic, request handling, and reusable query rules. They are especially common in ASP.NET Core APIs, Clean Architecture, Domain-Driven Design, CQRS-style applications, and enterprise systems that use Entity Framework Core.
These patterns are useful because they help answer practical design questions:
- Where should database access code live?
- Should application services use
DbContextdirectly? - How do multiple database changes commit as one operation?
- How can controllers stay thin?
- How can validation, logging, authorization, transactions, and performance monitoring be applied consistently?
- How can query criteria be reused without duplicating LINQ expressions?
- When does an abstraction improve maintainability, and when does it only add noise?
For interviews, this topic matters because candidates are often asked to explain not only what these patterns are, but also when they are useful and when they are unnecessary. A strong answer should show judgment. These patterns can make a large system easier to maintain, but they can also create over-engineered code if applied mechanically.
In modern .NET, this topic is especially nuanced because EF Core's DbContext already behaves like a Unit of Work and its DbSet<TEntity> behaves somewhat like a Repository. That means adding a custom Repository or Unit of Work layer is not always required. The right choice depends on the complexity of the domain, the need for persistence isolation, testing strategy, query complexity, team conventions, and architectural boundaries.
Common real-world use cases include:
- A Clean Architecture API where the Application layer defines repository interfaces and Infrastructure implements them with EF Core.
- A CQRS-style API where controllers send commands and queries through a mediator.
- A domain model where aggregate roots are loaded and saved through repositories.
- A reporting screen where reusable specifications describe filters, sorting, includes, and pagination.
- A large codebase where cross-cutting behavior is centralized through mediator pipeline behaviors.
The key interview skill is being able to explain the trade-offs clearly: these patterns are tools, not mandatory layers.
Core Concepts
Pattern summary
Repository pattern
The Repository pattern provides an abstraction over data access. It represents a collection-like interface for loading, adding, updating, and removing domain objects without exposing database-specific implementation details to the domain or application layer.
A repository is not just a wrapper around every EF Core method. A useful repository expresses meaningful persistence operations for a use case or aggregate.
Example:
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid orderId, CancellationToken cancellationToken);
Task AddAsync(Order order, CancellationToken cancellationToken);
}
EF Core implementation:
public class EfCoreOrderRepository : IOrderRepository
{
private readonly AppDbContext _dbContext;
public EfCoreOrderRepository(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<Order?> GetByIdAsync(Guid orderId, CancellationToken cancellationToken)
{
return _dbContext.Orders
.Include(order => order.Items)
.FirstOrDefaultAsync(order => order.Id == orderId, cancellationToken);
}
public async Task AddAsync(Order order, CancellationToken cancellationToken)
{
await _dbContext.Orders.AddAsync(order, cancellationToken);
}
}
Usage from an application service or handler:
public class PlaceOrderHandler
{
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _unitOfWork;
public PlaceOrderHandler(IOrderRepository orders, IUnitOfWork unitOfWork)
{
_orders = orders;
_unitOfWork = unitOfWork;
}
public async Task<Guid> HandleAsync(
PlaceOrderCommand command,
CancellationToken cancellationToken)
{
var order = Order.Create(command.CustomerId, command.Items);
await _orders.AddAsync(order, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return order.Id;
}
}
Why Repository is useful
Repository is useful when it protects the application or domain layer from persistence details. It can hide EF Core query shape, includes, tracking rules, SQL-specific behavior, and persistence concerns.
It is especially useful when:
- The domain model should not depend on EF Core directly.
- The application follows Clean Architecture or DDD.
- Persistence logic is complex and should be centralized.
- Multiple persistence technologies may be used.
- Queries need consistent includes, filters, or tracking behavior.
- You want to test application logic without EF Core.
- You want repository methods to communicate intent, such as
GetPendingOrdersForCustomerAsync.
When Repository is not useful
Repository can be unnecessary when it only duplicates DbSet<TEntity> methods:
public interface IRepository<T>
{
Task<T?> GetByIdAsync(Guid id);
Task<List<T>> GetAllAsync();
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
}
This kind of generic repository often adds little value because EF Core already provides:
- Query composition through LINQ.
- Change tracking.
- Entity sets through
DbSet<TEntity>. - Unit of Work behavior through
DbContext. - Transactions through
SaveChangesand explicit transaction APIs.
A generic repository can also hide important EF Core capabilities such as Include, projection, split queries, compiled queries, tracking/no-tracking configuration, and provider-specific optimizations.
A practical rule is:
Use repositories when they express business-oriented persistence operations.
Avoid repositories when they are only thin wrappers around EF Core CRUD.
DbContext as Repository and Unit of Work
EF Core's DbContext already has characteristics of both Repository and Unit of Work:
DbSet<TEntity>represents a collection of entities and supports querying and persistence operations.DbContexttracks changes to entities.SaveChangesorSaveChangesAsynccommits all tracked changes as a unit.- EF Core can wrap changes in a transaction depending on the provider and operation.
Example without a custom repository:
public class ProductsController : ControllerBase
{
private readonly AppDbContext _dbContext;
public ProductsController(AppDbContext dbContext)
{
_dbContext = dbContext;
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProductDto>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var product = await _dbContext.Products
.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new ProductDto(x.Id, x.Name, x.Price))
.FirstOrDefaultAsync(cancellationToken);
return product is null ? NotFound() : Ok(product);
}
}
This can be perfectly acceptable for simple CRUD applications. The issue is not direct DbContext usage itself. The issue is whether direct usage leaks persistence concerns into places where they make the application harder to maintain.
Unit of Work pattern
The Unit of Work pattern coordinates changes to multiple objects and commits them as one operation. If one part fails, the whole operation should fail.
In EF Core, the simplest Unit of Work is often the DbContext itself:
order.MarkAsPaid();
customer.AddLoyaltyPoints(order.Total);
await dbContext.SaveChangesAsync(cancellationToken);
All tracked changes are persisted together.
Some architectures add a small IUnitOfWork abstraction:
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
Implementation:
public class EfCoreUnitOfWork : IUnitOfWork
{
private readonly AppDbContext _dbContext;
public EfCoreUnitOfWork(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
{
return _dbContext.SaveChangesAsync(cancellationToken);
}
}
This can keep application services independent from EF Core while still allowing EF Core to perform the actual work.
Explicit transactions
Sometimes a use case needs an explicit transaction, especially when multiple saves, raw SQL, or multiple operations must be coordinated.
public async Task CompleteCheckoutAsync(
CheckoutCommand command,
CancellationToken cancellationToken)
{
await using var transaction =
await _dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
var order = await _dbContext.Orders
.FirstAsync(x => x.Id == command.OrderId, cancellationToken);
order.MarkAsPaid();
await _dbContext.SaveChangesAsync(cancellationToken);
var shipment = Shipment.Create(order.Id, command.Address);
await _dbContext.Shipments.AddAsync(shipment, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
In many cases, a single SaveChangesAsync is cleaner. Explicit transactions should be used when the use case really needs transaction boundaries beyond the default behavior.
Unit of Work mistakes
Common mistakes include:
- Adding an
IUnitOfWorkthat only duplicatesDbContextwithout architectural benefit. - Calling
SaveChangesAsyncinside every repository method, which prevents coordinating multiple changes. - Creating multiple
DbContextinstances inside one business operation unintentionally. - Hiding transaction behavior so callers cannot reason about consistency.
- Trying to use one Unit of Work across long-running workflows or user sessions.
- Mixing database transactions with external services such as email or payment APIs without understanding distributed consistency.
A good practice is to keep one short-lived unit of work per web request or per application use case.
Repository and Unit of Work together
Repository and Unit of Work are often used together:
- Repository loads and stores aggregates.
- Unit of Work commits changes.
Example:
public interface ICustomerRepository
{
Task<Customer?> GetByIdAsync(Guid customerId, CancellationToken cancellationToken);
}
public class UpdateCustomerEmailHandler
{
private readonly ICustomerRepository _customers;
private readonly IUnitOfWork _unitOfWork;
public UpdateCustomerEmailHandler(
ICustomerRepository customers,
IUnitOfWork unitOfWork)
{
_customers = customers;
_unitOfWork = unitOfWork;
}
public async Task HandleAsync(
UpdateCustomerEmailCommand command,
CancellationToken cancellationToken)
{
var customer = await _customers.GetByIdAsync(
command.CustomerId,
cancellationToken);
if (customer is null)
throw new InvalidOperationException("Customer not found.");
customer.ChangeEmail(command.NewEmail);
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
}
Notice that the repository does not save immediately. It retrieves an entity and lets the application operation decide when to commit.
Aggregate-focused repositories
In DDD-style applications, repositories are usually designed around aggregate roots, not every database table.
Example:
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid orderId, CancellationToken cancellationToken);
Task AddAsync(Order order, CancellationToken cancellationToken);
}
The repository works with Order, not directly with OrderItem if OrderItem belongs inside the Order aggregate. This protects invariants and prevents unrelated parts of the app from modifying child entities incorrectly.
Good aggregate repository methods describe intent:
Task<Order?> GetPendingOrderForCustomerAsync(
Guid customerId,
CancellationToken cancellationToken);
Task<IReadOnlyList<Order>> GetOrdersReadyForShipmentAsync(
CancellationToken cancellationToken);
Poor repository methods expose generic persistence details:
IQueryable<Order> Query();
void Attach(Order order);
void SetEntityState(Order order, EntityState state);
These may be useful in infrastructure code, but they often leak EF Core details into the application layer.
Mediator pattern
The Mediator pattern reduces direct coupling between objects by having them communicate through a mediator. In .NET applications, this is commonly seen in command/query handlers.
Instead of a controller directly calling many services, the controller sends a request object:
public record CreateProductCommand(
string Name,
decimal Price) : IRequest<Guid>;
Handler:
public class CreateProductHandler
: IRequestHandler<CreateProductCommand, Guid>
{
private readonly AppDbContext _dbContext;
public CreateProductHandler(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Guid> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
var product = new Product(request.Name, request.Price);
await _dbContext.Products.AddAsync(product, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
return product.Id;
}
}
Controller:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly ISender _sender;
public ProductsController(ISender sender)
{
_sender = sender;
}
[HttpPost]
public async Task<ActionResult<Guid>> Create(
CreateProductCommand command,
CancellationToken cancellationToken)
{
var productId = await _sender.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = productId }, productId);
}
[HttpGet("{id:guid}")]
public IActionResult GetById(Guid id)
{
return Ok();
}
}
The controller only knows how to receive HTTP input and send a request. The handler contains the use-case logic.
Mediator vs CQRS
Mediator and CQRS are related but not the same.
- Mediator is a communication pattern.
- CQRS separates commands that change state from queries that read state.
- A mediator library can help implement CQRS-style handlers.
- You can use mediator without full CQRS.
- You can use CQRS without a mediator library.
Example command:
public record CancelOrderCommand(Guid OrderId) : IRequest;
Example query:
public record GetOrderDetailsQuery(Guid OrderId) : IRequest<OrderDetailsDto?>;
A command should usually express an intent and may return minimal data. A query should return data and should not change state.
Mediator pipeline behaviors
One major reason mediator patterns are popular in .NET is pipeline behavior. A pipeline behavior wraps handlers and applies cross-cutting concerns consistently.
Examples:
- Validation.
- Logging.
- Authorization.
- Performance measurement.
- Transaction handling.
- Exception handling.
- Idempotency.
- Retry policies for safe operations.
Example validation behavior:
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var context = new ValidationContext<TRequest>(request);
var errors = _validators
.Select(validator => validator.Validate(context))
.SelectMany(result => result.Errors)
.Where(error => error is not null)
.ToList();
if (errors.Count > 0)
throw new ValidationException(errors);
return await next();
}
}
This keeps validation out of every controller and handler.
When Mediator is useful
Mediator is useful when:
- Controllers are becoming too large.
- Use cases should be isolated into one handler each.
- Commands and queries need consistent cross-cutting behavior.
- The team wants a clear application layer boundary.
- CQRS-style organization improves readability.
- Application workflows are easier to test as request handlers.
When Mediator is not useful
Mediator can be unnecessary or harmful when:
- The application is small and simple.
- Every endpoint only forwards to a handler with one line of code.
- Developers struggle to navigate request-to-handler flow.
- The mediator becomes a hidden service locator.
- Business logic is scattered across too many tiny classes.
- Pipeline behaviors hide important control flow.
A practical rule is:
Use mediator when it clarifies use cases and centralizes cross-cutting behavior.
Avoid mediator when it only adds ceremony.
Specification pattern
The Specification pattern encapsulates a rule or criteria that can be reused, combined, and tested. In .NET persistence code, a specification often contains:
- Filter expression.
- Includes.
- Sorting.
- Pagination.
- Projection.
- Tracking behavior.
- Business rule intent.
Simple specification interface:
public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
}
Example specification:
public class ActiveCustomersByCountrySpecification
: ISpecification<Customer>
{
public ActiveCustomersByCountrySpecification(string countryCode)
{
Criteria = customer =>
customer.IsActive &&
customer.CountryCode == countryCode;
}
public Expression<Func<Customer, bool>> Criteria { get; }
}
Usage:
public Task<List<Customer>> ListAsync(
ISpecification<Customer> specification,
CancellationToken cancellationToken)
{
return _dbContext.Customers
.Where(specification.Criteria)
.ToListAsync(cancellationToken);
}
A richer specification can include query-shaping rules:
public abstract class Specification<T>
{
public Expression<Func<T, bool>>? Criteria { get; protected set; }
public List<Expression<Func<T, object>>> Includes { get; } = new();
public Expression<Func<T, object>>? OrderBy { get; protected set; }
public int? Skip { get; protected set; }
public int? Take { get; protected set; }
}
Example:
public class RecentPaidOrdersSpecification : Specification<Order>
{
public RecentPaidOrdersSpecification(Guid customerId, int take)
{
Criteria = order =>
order.CustomerId == customerId &&
order.Status == OrderStatus.Paid;
Includes.Add(order => order.Items);
OrderBy = order => order.CreatedAt;
Take = take;
}
}
Specification as business rule vs query rule
There are two common meanings of "specification":
Business rule specification
A business rule specification answers whether an object satisfies a rule.
public interface IBusinessSpecification<T>
{
bool IsSatisfiedBy(T entity);
}
public class OrderCanBeCancelledSpecification
: IBusinessSpecification<Order>
{
public bool IsSatisfiedBy(Order order)
{
return order.Status is OrderStatus.Pending or OrderStatus.Paid
&& !order.HasShipped;
}
}
This is useful for domain logic.
Query specification
A query specification describes how to retrieve data.
public class PendingOrdersForCustomerSpecification
{
public Expression<Func<Order, bool>> Criteria { get; }
public PendingOrdersForCustomerSpecification(Guid customerId)
{
Criteria = order =>
order.CustomerId == customerId &&
order.Status == OrderStatus.Pending;
}
}
This is useful for repositories and read models.
Both forms are valid, but they solve different problems. In interviews, make that distinction clear.
Specification vs exposing IQueryable
Some repositories expose IQueryable<T>:
IQueryable<Order> Query();
This gives callers maximum flexibility, but it also leaks persistence details and makes it harder to control query behavior.
Problems with exposing IQueryable<T> from application abstractions:
- Callers can build inefficient queries.
- EF Core-specific translation concerns leak upward.
- Query execution timing becomes less obvious.
- Includes, tracking, split queries, pagination, and projection may become inconsistent.
- It is harder to enforce aggregate boundaries.
Specification is one alternative. It allows reusable query intent without exposing the full query provider.
However, specifications can also become too complex. For reporting screens or highly dynamic search, direct query objects or dedicated read services may be clearer.
Query services and read models
Not every read operation needs a repository. In CQRS-style systems, queries often use dedicated query handlers or read services that project directly to DTOs.
Example:
public class GetOrderDetailsHandler
: IRequestHandler<GetOrderDetailsQuery, OrderDetailsDto?>
{
private readonly AppDbContext _dbContext;
public GetOrderDetailsHandler(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<OrderDetailsDto?> Handle(
GetOrderDetailsQuery request,
CancellationToken cancellationToken)
{
return _dbContext.Orders
.AsNoTracking()
.Where(order => order.Id == request.OrderId)
.Select(order => new OrderDetailsDto
{
Id = order.Id,
CustomerName = order.Customer.Name,
Total = order.Items.Sum(item => item.UnitPrice * item.Quantity)
})
.FirstOrDefaultAsync(cancellationToken);
}
}
This can be better than forcing all reads through aggregate repositories, especially for screens that need projections, joins, pagination, and sorting.
A practical design often looks like:
- Use repositories for aggregate write operations.
- Use query handlers or read services for read models and projections.
- Use specifications when query criteria are reused or complex.
How the patterns work together
A common Clean Architecture flow:
HTTP request
-> Controller
-> Mediator sends command/query
-> Handler executes use case
-> Repository loads aggregate
-> Domain model applies business rules
-> Unit of Work commits changes
-> Pipeline behaviors apply validation/logging/transactions
Example:
public record CancelOrderCommand(Guid OrderId) : IRequest;
public class CancelOrderHandler : IRequestHandler<CancelOrderCommand>
{
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _unitOfWork;
public CancelOrderHandler(
IOrderRepository orders,
IUnitOfWork unitOfWork)
{
_orders = orders;
_unitOfWork = unitOfWork;
}
public async Task Handle(
CancelOrderCommand request,
CancellationToken cancellationToken)
{
var order = await _orders.GetByIdAsync(request.OrderId, cancellationToken);
if (order is null)
throw new InvalidOperationException("Order not found.");
order.Cancel();
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
}
This design keeps the controller thin, puts use-case logic in the handler, hides persistence details behind a repository, and commits through the Unit of Work.
Transaction behavior with Mediator
Some applications use a transaction pipeline behavior for commands:
public class TransactionBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
private readonly AppDbContext _dbContext;
public TransactionBehavior(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
await using var transaction =
await _dbContext.Database.BeginTransactionAsync(cancellationToken);
var response = await next();
await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return response;
}
}
This centralizes transaction behavior, but it must be used carefully.
Potential issues:
- Queries should not open write transactions unnecessarily.
- Handlers should not also call
SaveChangesAsyncinconsistently. - External side effects such as email or message publishing should not be treated as part of the database transaction unless using an outbox or similar pattern.
- Nested transactions and multiple
DbContextinstances can complicate behavior. - Some operations may need different transaction isolation or no transaction.
A common production approach is to use a transaction behavior only for commands and keep side effects reliable through patterns like outbox messaging.
When to use each pattern
Use Repository when
- You have domain-focused aggregate persistence.
- You want to hide EF Core from the application or domain layer.
- Persistence logic is complex or repeated.
- You need a stable application boundary.
- You want to enforce aggregate access rules.
- Repository methods express business intent.
Avoid Repository when
- The app is simple CRUD.
- The repository only mirrors EF Core.
- The abstraction leaks
IQueryable,DbSet, or EF state management everywhere. - It prevents efficient projection or query optimization.
- It creates a lot of boilerplate with no design benefit.
Use Unit of Work when
- Multiple changes must be committed as one operation.
- You want an abstraction over
SaveChangesAsync. - You need explicit transaction coordination.
- You want handlers/services to avoid depending on
DbContext.
Avoid custom Unit of Work when
DbContextis already visible and sufficient.- The custom abstraction only forwards to
SaveChangesAsync. - It hides important transaction boundaries.
- It encourages long-lived contexts.
Use Mediator when
- Controllers are thin and handlers represent use cases.
- You want command/query organization.
- Cross-cutting behavior can be centralized in pipeline behaviors.
- Use cases need isolated tests.
- The application layer benefits from clear request/handler structure.
Avoid Mediator when
- It adds ceremony without reducing complexity.
- The app is small and direct service calls are clearer.
- Developers cannot easily trace flow.
- The mediator is used as a service locator.
- Every handler becomes a one-line pass-through.
Use Specification when
- Query rules are reused.
- Criteria are complex and need names.
- You want to compose filters consistently.
- You want to avoid leaking
IQueryableupward. - You need testable query intent.
Avoid Specification when
- The query is simple and used once.
- Specifications become a custom query language.
- They make EF Core optimization harder.
- They hide too much behavior.
- Direct LINQ or a dedicated query handler is clearer.
Common mistakes
Overusing generic repositories
A generic repository can be useful in limited cases, but it is often overused.
Problem:
public interface IRepository<T>
{
IQueryable<T> Query();
Task<T?> GetByIdAsync(Guid id);
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
}
This may look reusable, but it can:
- Leak
IQueryable. - Ignore aggregate boundaries.
- Provide operations that should not exist for all entities.
- Duplicate EF Core.
- Encourage an anemic data-access style.
A better approach is often intent-based repositories or direct query handlers.
Saving inside repositories
This is usually a bad default:
public async Task AddAsync(Order order)
{
await _dbContext.Orders.AddAsync(order);
await _dbContext.SaveChangesAsync();
}
The problem is that every repository method commits immediately. A use case that needs to update an order and a customer together can no longer coordinate the transaction cleanly.
Better:
public async Task AddAsync(Order order, CancellationToken cancellationToken)
{
await _dbContext.Orders.AddAsync(order, cancellationToken);
}
Then the handler or Unit of Work commits once.
Using Mediator as a service locator
A mediator should not become a way to hide dependencies or avoid clear design. If a handler sends many nested commands to perform one operation, the flow may become hard to reason about.
Example warning sign:
await _sender.Send(new StepOneCommand(...));
await _sender.Send(new StepTwoCommand(...));
await _sender.Send(new StepThreeCommand(...));
Sometimes orchestration belongs in an application service or workflow object instead.
Mixing business and persistence specifications
A business specification that uses normal C# methods may not translate to SQL. A query specification using expression trees may be designed for EF Core translation. Mixing the two without care can cause runtime translation errors or force client-side evaluation.
Keep the purpose clear:
- Business rule specifications evaluate domain behavior.
- Query specifications describe provider-translatable data access rules.
Hiding performance problems
Abstractions should not prevent performance-aware design. Repositories and specifications should still allow:
- Projection to DTOs.
AsNoTrackingfor read-only queries.- Pagination.
- Index-friendly filters.
- Avoiding N+1 queries.
- Appropriate includes or split queries.
- Cancellation tokens.
- Async execution.
A pattern that hides these concerns can make the system slower and harder to diagnose.
Best practices
Use these practical habits in .NET applications:
- Start simple and add patterns when they solve real design pressure.
- Prefer intent-based repository methods over generic CRUD methods.
- Treat EF Core
DbContextas the default Unit of Work unless an abstraction adds value. - Commit once per use case when possible.
- Keep transaction boundaries explicit and short.
- Use mediator handlers to represent application use cases, not tiny wrappers around services.
- Use pipeline behaviors for cross-cutting concerns that apply consistently.
- Keep query logic close to the read use case when projection and performance matter.
- Use specifications for reusable criteria, not for every simple query.
- Avoid exposing
IQueryable<T>from high-level application abstractions unless the design intentionally allows query composition. - Always pass
CancellationTokenthrough async data access and handlers. - Do not use mocks as a substitute for integration tests.
- Validate the real EF Core mapping, transactions, DI registration, and query behavior with integration tests.
- Prefer clear code over pattern-heavy code.
Practical decision guide
Use this decision guide during design discussions and interviews:
Is this a simple CRUD application?
-> Direct DbContext may be enough.
Do I need to protect domain/application logic from persistence details?
-> Consider Repository.
Do multiple changes need to commit together?
-> Use DbContext SaveChanges or a Unit of Work abstraction.
Are controllers becoming large and use cases hard to test?
-> Consider Mediator with command/query handlers.
Do many handlers need validation, logging, authorization, or transaction behavior?
-> Consider mediator pipeline behaviors.
Is a query rule reused or complex enough to deserve a name?
-> Consider Specification.
Is the abstraction mostly forwarding to EF Core with no added meaning?
-> Avoid the pattern or simplify it.
Good architecture is not about using every pattern. It is about choosing the smallest design that keeps the system understandable, testable, and safe to change.