CQRS and Vertical Slice Architecture answer different questions. CQRS decides how reads and writes are modeled. Vertical slices decide where the code lives. Put them together and every command and query becomes a small, self-contained unit that you can optimize on its own. Here is how the combination works in practice, from folder structure to endpoints and separate read stores.
Why Combine CQRS and Vertical Slices?
CQRS separates reads from writes. Vertical Slice Architecture organizes code by feature. Together, each command and query becomes an independent slice with its own data access strategy.
- Commands use EF Core with the change tracker
- Queries use Dapper or raw SQL for performance
- Each slice picks the tool that fits
Project Structure
Features/
Orders/
Commands/
PlaceOrder.cs
CancelOrder.cs
UpdateOrderStatus.cs
Queries/
GetOrder.cs
GetOrders.cs
SearchOrders.cs
OrdersModule.cs
Or flatten it when the feature count is small:
Features/
Orders/
PlaceOrder.cs ← Command
CancelOrder.cs ← Command
GetOrder.cs ← Query
GetOrders.cs ← Query
Command Slice
A command changes state:
public static class PlaceOrder
{
public sealed record Command(
Guid CustomerId,
List<ItemRequest> Items) : IRequest<Result<Guid>>;
public sealed record ItemRequest(
Guid ProductId, int Quantity);
public sealed class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(x => x.ProductId).NotEmpty();
item.RuleFor(x => x.Quantity).GreaterThan(0);
});
}
}
public sealed class Handler : IRequestHandler<Command, Result<Guid>>
{
private readonly ApplicationDbContext _db;
public Handler(ApplicationDbContext db) => _db = db;
public async Task<Result<Guid>> Handle(
Command request, CancellationToken ct)
{
var order = Order.Create(
request.CustomerId,
request.Items.Select(i =>
new LineItem(i.ProductId, i.Quantity)).ToList());
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return order.Id;
}
}
}
Commands go through EF Core - the change tracker handles inserts, updates, and domain event dispatch.
Query Slice
A query reads data without side effects:
public static class GetOrder
{
public sealed record Query(Guid OrderId) : IRequest<OrderResponse?>;
public sealed record OrderResponse(
Guid Id,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
List<LineItemResponse> Items);
public sealed record LineItemResponse(
Guid ProductId,
int Quantity,
decimal UnitPrice);
// Intermediate row type - Dapper maps columns to this,
// since OrderResponse's constructor also expects Items
private sealed record OrderRow(
Guid Id,
string Status,
decimal TotalAmount,
DateTime CreatedAt);
public sealed class Handler : IRequestHandler<Query, OrderResponse?>
{
private readonly IDbConnection _connection;
public Handler(IDbConnection connection) =>
_connection = connection;
public async Task<OrderResponse?> Handle(
Query query, CancellationToken ct)
{
const string sql = """
SELECT o.Id, o.Status, o.TotalAmount, o.CreatedAt
FROM Orders o
WHERE o.Id = @OrderId;
SELECT li.ProductId, li.Quantity, li.UnitPrice
FROM LineItems li
WHERE li.OrderId = @OrderId;
""";
using var multi = await _connection
.QueryMultipleAsync(sql, new { query.OrderId });
var order = await multi
.ReadSingleOrDefaultAsync<OrderRow>();
if (order is null) return null;
var items = (await multi
.ReadAsync<LineItemResponse>()).ToList();
return new OrderResponse(
order.Id,
order.Status,
order.TotalAmount,
order.CreatedAt,
items);
}
}
}
Queries use Dapper for fast, lightweight reads. No change tracking overhead. No mapping layers.
Typed Abstractions
One caveat before you build everything on IRequest: CQRS is not MediatR. The pattern is the read/write separation; the library is one way to dispatch it. That said, marker interfaces make the separation explicit and unlock selective behaviors:
public interface ICommand<TResponse> : IRequest<TResponse>;
public interface IQuery<TResponse> : IRequest<TResponse>;
public interface ICommandHandler<TCommand, TResponse>
: IRequestHandler<TCommand, TResponse>
where TCommand : ICommand<TResponse>;
public interface IQueryHandler<TQuery, TResponse>
: IRequestHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse>;
Now you can apply behaviors selectively:
// Validation only runs for commands
public sealed class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand<TResponse>
{
// ...
}
// Caching only runs for queries
public sealed class CachingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IQuery<TResponse>
{
// ...
}
Endpoint Registration
Map commands and queries to HTTP methods:
public class OrdersModule : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders").WithTags("Orders");
// Commands → POST, PUT, DELETE
group.MapPost("/", async (
PlaceOrder.Command command, ISender sender) =>
{
var result = await sender.Send(command);
return result.Match(
id => Results.Created($"/api/orders/{id}", id),
error => Results.Problem(error.Description));
});
group.MapDelete("/{id:guid}", async (
Guid id, ISender sender) =>
{
var result = await sender.Send(
new CancelOrder.Command(id));
return result.Match(
() => Results.NoContent(),
error => Results.Problem(error.Description));
});
// Queries → GET
group.MapGet("/{id:guid}", async (
Guid id, ISender sender) =>
{
var order = await sender.Send(new GetOrder.Query(id));
return order is not null
? Results.Ok(order)
: Results.NotFound();
});
group.MapGet("/", async (
[AsParameters] GetOrders.Query query, ISender sender) =>
{
var result = await sender.Send(query);
return Results.Ok(result);
});
}
}
Separate Read and Write Models
CQRS lets commands and queries use different data shapes:
// Write model (rich domain entity)
public class Order : AggregateRoot
{
private readonly List<LineItem> _lineItems = [];
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public void AddLineItem(Guid productId, int quantity, Money price)
{
// Business rules...
}
}
// Read model (flat DTO optimized for display)
public sealed record OrderListItem(
Guid Id,
string CustomerName, // Joined from Customers table
string Status,
decimal TotalAmount,
int ItemCount, // Computed
DateTime CreatedAt);
Write models enforce domain invariants. Read models are flat DTOs optimized for the UI.
Scaling: Separate Read Database
For high-traffic applications, CQRS enables separate read and write stores. The write side doesn't change: commands keep going through EF Core to the normalized schema. The read side gets its own denormalized table (or a read replica connection), and the query slice is the only code that needs to know:
public static class GetOrderSummary
{
// Flat read model, so Dapper maps it directly
public sealed record OrderSummary(
Guid Id,
string Status,
decimal TotalAmount,
DateTime CreatedAt);
public sealed record Query(Guid OrderId) : IQuery<OrderSummary?>;
public sealed class Handler : IQueryHandler<Query, OrderSummary?>
{
private readonly IDbConnection _readDb; // Dapper → read replica
public Handler(IDbConnection readDb) => _readDb = readDb;
public async Task<OrderSummary?> Handle(
Query query, CancellationToken ct)
{
const string sql = """
SELECT Id, Status, TotalAmount, CreatedAt
FROM OrderSummaries
WHERE Id = @OrderId;
""";
return await _readDb.QuerySingleOrDefaultAsync<OrderSummary>(
sql, new { query.OrderId });
}
}
}
Start with a single database. Split when performance demands it.
Where This Combination Struggles
Two honest caveats before you commit:
- Eventual consistency creeps in early. The moment queries read from a cache or replica, a command's result may not be immediately visible to the next query. Design your UI for it (return the created resource from the command, don't re-query).
- Slice independence is a discipline, not a guarantee. It's tempting to share DTOs between a command and its neighboring query "because they look the same." Resist it. The whole point is that each side can evolve without breaking the other. When slices genuinely need shared logic, pull it out deliberately, which I covered in where the shared logic lives in VSA.
Summary
CQRS + Vertical Slice Architecture:
- Each command and query is its own slice - independent and self-contained
- Commands use EF Core for change tracking and domain events
- Queries use Dapper for fast, lightweight reads
- Pipeline behaviors can target commands or queries selectively
- Read and write models are separate - optimize each independently
- Start simple - one database, split read/write stores when needed
The combination gives you the organizational clarity of VSA with the optimization potential of CQRS.
Thanks for reading, and stay awesome!
Frequently Asked Questions
Do vertical slices require CQRS?
No. Vertical Slice Architecture only says to organize code by feature. But since CQRS already splits behavior into commands and queries, each one maps naturally onto its own slice, so the two patterns reinforce each other.
Do I need MediatR to combine vertical slices with CQRS?
No. CQRS is about separating reads from writes, not about a specific library. You can implement slices with plain handler classes, minimal API endpoints, or your own ICommandHandler and IQueryHandler abstractions.
Can commands and queries use different data access in the same app?
Yes, and that is one of the main benefits. Commands typically use EF Core for change tracking and domain logic, while queries use Dapper or raw SQL for fast, allocation-light reads. Each slice picks its own tool.
Does CQRS mean I need two databases?
No. Most systems run CQRS against a single database with different code paths for reads and writes. Separate read stores or replicas are an optimization you add later, only if measured load demands it.



