CQRS with MediatR vs plain services is the most common architecture debate in .NET 10 development, and both sides are right depending on context. A plain service exposes methods you call directly, like orderService.CreateAsync(order). MediatR routes the same operation through a request and a dedicated handler, like mediator.Send(new CreateOrderCommand(order)). The choice affects coupling, control flow, testability, and the cost of adding cross-cutting concerns across the whole application.
This article compares the two patterns side by side with the same feature implemented both ways. You will see concrete code for each approach, a summary of the trade-offs, and clear guidance on when to choose plain services and when CQRS with MediatR is worth the extra structure.
The Plain Service Baseline
A plain service is a class registered in dependency injection that contains related business methods. For a small order feature, the service takes the database context, performs the work, and returns the result. Everything is direct: the controller or component references the service by its interface and calls a method.
public interface IOrderService
{
Task<OrderDto> CreateAsync(CreateOrderRequest request);
Task<List<OrderDto>> GetRecentAsync(int count);
}
public class OrderService : IOrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task<OrderDto> CreateAsync(CreateOrderRequest request)
{
var order = Order.Create(request.CustomerId, request.Items);
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return OrderDto.FromEntity(order);
}
public Task<List<OrderDto>> GetRecentAsync(int count)
=> _db.Orders.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Take(count)
.Select(OrderDto.FromEntity)
.ToListAsync();
}
This is hard to beat for simplicity. The caller knows exactly what happens, the debugger walks straight into the method, and the whole feature fits in a handful of lines. For small applications, plain services are often the right answer and nobody should feel guilty about using them.
What CQRS with MediatR Changes
The same feature with CQRS with MediatR splits each operation into its own command or query and its own handler. The caller sends the request through IMediator, and MediatR resolves the single matching handler and runs any registered pipeline behaviors around it.
public record CreateOrderCommand(string CustomerId, List<OrderItem> Items)
: IRequest<OrderDto>;
public class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, OrderDto>
{
private readonly AppDbContext _db;
public CreateOrderHandler(AppDbContext db) => _db = db;
public async Task<OrderDto> Handle(
CreateOrderCommand command, CancellationToken ct)
{
var order = Order.Create(command.CustomerId, command.Items);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return OrderDto.FromEntity(order);
}
}
public record GetRecentOrdersQuery(int Count) : IRequest<List<OrderDto>>;
public class GetRecentOrdersHandler
: IRequestHandler<GetRecentOrdersQuery, List<OrderDto>>
{
private readonly AppDbContext _db;
public GetRecentOrdersHandler(AppDbContext db) => _db = db;
public Task<List<OrderDto>> Handle(
GetRecentOrdersQuery query, CancellationToken ct)
=> _db.Orders.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Take(query.Count)
.Select(OrderDto.FromEntity)
.ToListAsync(ct);
}
Functionally, both versions create orders and list recent ones. The difference is in the seams. The mediator introduces a single dispatch point where behaviors can intercept every operation, and each handler becomes independently replaceable and testable.
There is also a practical difference in how the two designs grow. Adding a third operation to the service means editing the interface, the implementation, and every unit test that mocks the interface. Adding a third command to the MediatR design means creating one request record, one handler class, and one test for the handler. Existing callers and mocks do not change, because they depend on the request type rather than on a method signature.
Trade-Offs: Coupling, Control Flow, and Testing
- Coupling: Plain services couple the caller to a specific service interface. MediatR couples the caller only to the request type, so the handler can be swapped without touching callers.
- Control flow: With plain services, cross-cutting logic lives in each method or in base classes. With MediatR, it lives in pipeline behaviors that apply uniformly.
- Testing: Handlers are single classes with constructor dependencies, easy to test in isolation. Services work too, but behavior-heavy services accumulate dependencies.
- Complexity: MediatR adds classes per operation and indirection. Plain services keep everything explicit and easier for beginners.
- Performance: The mediator overhead is negligible in business applications; it is a dictionary lookup plus a delegate call.
When Plain Services Are the Better Choice
Choose plain services for small applications, prototypes, and simple CRUD where every operation is a direct database call with no shared cross-cutting concerns. If the project has fewer than a dozen endpoints and the team values explicit control flow above all else, a service class with well-named methods is the pragmatic answer. The Indotalent free products are deliberately simple so that newcomers can read the whole codebase quickly. If the application is expected to stay small for its entire lifetime, the ceremony of commands, handlers, and pipeline registration is overhead you will never reclaim.
When to Reach for CQRS with MediatR
Reach for CQRS with MediatR when operations multiply, cross-cutting concerns appear, and multiple developers work on the same codebase. Once you need validation on every command, logging on every query, and transactions on every write, a service-based design duplicates that logic or buries it in base classes. A pipeline applies it once, and handlers shrink to the point where a review of a single command takes minutes instead of an afternoon. That is the moment the mediator pattern pays for its indirection.
Every commercial Indotalent product ships with this architecture: commands, queries, and handlers inside Vertical Slice slices, with validation, logging, and transaction behaviors in the pipeline. When you study the source code you are studying that pattern applied to real business domains.
Key Takeaways
- Plain services are direct, explicit, and perfect for small applications
- CQRS with MediatR adds one dispatch point and uniform pipeline behaviors
- The main win is decoupling callers from implementations
- Choose based on operation count, shared concerns, and team size