# Combining Vertical Slices With CQRS in .NET

> CQRS and Vertical Slice Architecture are a natural pair. Commands and queries are already separate - putting each in its own slice makes them independent and easy to optimize.

Published: 2026-08-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/combining-vertical-slices-cqrs

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**](https://milanjovanovic.tech/blog/cqrs-pattern-the-way-it-should-have-been-from-the-start) separates reads from writes. [**Vertical Slice Architecture**](https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet) organizes code by feature. Together, each command and query becomes an independent slice with its own data access strategy.

![An HTTP request routed by whether it is a command or a query: commands go to a command slice using EF Core with change tracking, queries go to a query slice using Dapper and raw SQL, both hitting the same database](https://milanjovanovic.tech/blogs/articles/combining-vertical-slices-cqrs/command-query-slices.png)

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

```csharp
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:

```csharp
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**](https://milanjovanovic.tech/blog/stop-conflating-cqrs-and-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:

```csharp
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:

```csharp
// 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:

```csharp
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:

```csharp
// 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:

```csharp
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**](https://milanjovanovic.tech/blog/vertical-slice-architecture-where-does-the-shared-logic-live).

## Summary

CQRS + Vertical Slice Architecture:

1. **Each command and query is its own slice** - independent and self-contained
2. **Commands use EF Core** for change tracking and domain events
3. **Queries use Dapper** for fast, lightweight reads
4. **Pipeline behaviors** can target commands or queries selectively
5. **Read and write models** are separate - optimize each independently
6. **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.
