DEV_NET_CORE
GET_STARTED
Design & ArchitectureSoftware design principles and common .NET patterns

Repository, Unit of Work, Mediator, and Specification Patterns in .NET

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 DbContext directly?
  • 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

PatternMain purposeCommon .NET implementationUseful whenRisk when overused
RepositoryEncapsulate persistence operations behind a collection-like abstractionInterfaces such as IOrderRepository, implemented using EF CoreDomain logic should not know persistence detailsGeneric CRUD repositories can duplicate EF Core and hide useful features
Unit of WorkCoordinate multiple changes as one transactionEF Core DbContext, explicit transaction wrapper, or IUnitOfWork abstractionMultiple changes must commit or fail togetherExtra wrapper can duplicate DbContext.SaveChangesAsync
MediatorDecouple request senders from request handlersMediatR-style command/query handlers and pipeline behaviorsControllers should stay thin and use cases should be isolatedToo many tiny handlers can make navigation harder
SpecificationEncapsulate reusable criteria or query rulesSpecification classes with expressions, includes, sorting, paginationQuery rules are reused or complexCan become a query framework that is harder than direct LINQ

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:

Code
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid orderId, CancellationToken cancellationToken);
    Task AddAsync(Order order, CancellationToken cancellationToken);
}

EF Core implementation:

Code
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:

Code
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:

Code
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 SaveChanges and 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:

Code
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.
  • DbContext tracks changes to entities.
  • SaveChanges or SaveChangesAsync commits 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:

Code
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:

Code
order.MarkAsPaid();
customer.AddLoyaltyPoints(order.Total);

await dbContext.SaveChangesAsync(cancellationToken);

All tracked changes are persisted together.

Some architectures add a small IUnitOfWork abstraction:

Code
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

Implementation:

Code
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.

Code
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 IUnitOfWork that only duplicates DbContext without architectural benefit.
  • Calling SaveChangesAsync inside every repository method, which prevents coordinating multiple changes.
  • Creating multiple DbContext instances 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:

Code
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:

Code
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:

Code
Task<Order?> GetPendingOrderForCustomerAsync(
    Guid customerId,
    CancellationToken cancellationToken);

Task<IReadOnlyList<Order>> GetOrdersReadyForShipmentAsync(
    CancellationToken cancellationToken);

Poor repository methods expose generic persistence details:

Code
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:

Code
public record CreateProductCommand(
    string Name,
    decimal Price) : IRequest<Guid>;

Handler:

Code
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:

Code
[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:

Code
public record CancelOrderCommand(Guid OrderId) : IRequest;

Example query:

Code
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:

Code
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:

Code
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:

Code
public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
}

Example specification:

Code
public class ActiveCustomersByCountrySpecification
    : ISpecification<Customer>
{
    public ActiveCustomersByCountrySpecification(string countryCode)
    {
        Criteria = customer =>
            customer.IsActive &&
            customer.CountryCode == countryCode;
    }

    public Expression<Func<Customer, bool>> Criteria { get; }
}

Usage:

Code
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:

Code
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:

Code
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.

Code
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.

Code
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>:

Code
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:

Code
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:

Code
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:

Code
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:

Code
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 SaveChangesAsync inconsistently.
  • 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 DbContext instances 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

  • DbContext is 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 IQueryable upward.
  • 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:

Code
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:

Code
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:

Code
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:

Code
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.
  • AsNoTracking for 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 DbContext as 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 CancellationToken through 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:

Code
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.

Interview Practice

PreviousRecognizing when a pattern improves maintainability vs when it adds unnecessary complexityNext UpSOLID Principles in .NET Design