BrandGhost
CQRS with Feature Slices in C#: Commands and Queries Per Feature

CQRS with Feature Slices in C#: Commands and Queries Per Feature

CQRS with Feature Slices in C#: Commands and Queries Per Feature

CQRS -- Command Query Responsibility Segregation -- and feature slicing are complementary ideas that fit naturally together. Feature slicing says "organize code by business feature." CQRS says "separate code that changes state from code that reads state." When you apply both, the result is feature folders where each use case is either a command or a query, with no ambiguity about what it does.

This article explains how CQRS with feature slices in C# works in practice: the structural conventions, the code patterns, and how to apply them without needing MediatR or any other dispatch framework.

CQRS in One Sentence

CQRS separates operations that change state (commands) from operations that read state (queries). Commands often return minimal data -- an ID or acknowledgment. Queries return data. This is a common convention, not a hard rule: returning the created resource from a command is acceptable when the client needs it immediately (for example, to redirect to the newly created item).

The rule is not about using different databases or event sourcing (though CQRS can be combined with both). It is about recognizing that changing state and reading state are different activities with different concerns, and keeping them separate in code.

// Command: changes state, returns acknowledgment
public sealed record CreateTaskCommand(string Title, Guid ProjectId);
public sealed record CreateTaskResult(Guid TaskId, bool Success);

// Query: reads state, returns data
public sealed record GetTasksQuery(Guid ProjectId, bool? IsCompleted = null);
public sealed record GetTasksResult(IReadOnlyList<TaskSummary> Tasks);

How CQRS Fits the Feature Slice Folder Structure

In a feature-sliced project, CQRS becomes a naming convention within each feature folder. Command use cases and query use cases get their own folders:

Features/
  Tasks/
    CreateTask/           <- COMMAND: changes state
      CreateTaskCommand.cs
      CreateTaskCommandHandler.cs
      CreateTaskResult.cs
      CreateTaskEndpoint.cs
    CompleteTask/         <- COMMAND: changes state
      CompleteTaskCommand.cs
      CompleteTaskCommandHandler.cs
      CompleteTaskEndpoint.cs
    GetTask/              <- QUERY: reads state
      GetTaskQuery.cs
      GetTaskQueryHandler.cs
      GetTaskResult.cs
      GetTaskEndpoint.cs
    GetTasks/             <- QUERY: reads state
      GetTasksQuery.cs
      GetTasksQueryHandler.cs
      GetTasksResult.cs
      GetTasksEndpoint.cs
  Projects/
    CreateProject/        <- COMMAND
      ...
    GetProjects/          <- QUERY
      ...

The naming says everything. Opening Features/Tasks/ and seeing CreateTask, CompleteTask, GetTask, GetTasks tells you which operations are write operations and which are read operations. The folder name communicates the intent.

Building the Command Side

A command handler changes state. It receives a command object, validates it, applies the change, and returns a result that indicates success or describes what happened.

// Features/Tasks/CreateTask/CreateTaskCommand.cs
namespace TaskTracker.Features.Tasks.CreateTask;

public sealed record CreateTaskCommand(
    string Title,
    string? Description,
    Guid ProjectId,
    DateTimeOffset? DueDate);
// Features/Tasks/CreateTask/CreateTaskResult.cs
namespace TaskTracker.Features.Tasks.CreateTask;

public sealed record CreateTaskResult(
    Guid TaskId,
    string Title,
    DateTimeOffset CreatedAt);
// Features/Tasks/CreateTask/CreateTaskCommandHandler.cs
namespace TaskTracker.Features.Tasks.CreateTask;

public sealed class CreateTaskCommandHandler
{
    private readonly AppDbContext _db;
    private readonly TimeProvider _time;

    public CreateTaskCommandHandler(AppDbContext db, TimeProvider time)
    {
        _db = db;
        _time = time;
    }

    public async Task<CreateTaskResult> HandleAsync(
        CreateTaskCommand command,
        CancellationToken cancellationToken = default)
    {
        var task = new TaskEntity
        {
            Id = Guid.NewGuid(),
            Title = command.Title.Trim(),
            Description = command.Description?.Trim(),
            ProjectId = command.ProjectId,
            DueDate = command.DueDate,
            CreatedAt = _time.GetUtcNow(),
            IsCompleted = false
        };

        _db.Tasks.Add(task);
        await _db.SaveChangesAsync(cancellationToken);

        return new CreateTaskResult(task.Id, task.Title, task.CreatedAt);
    }
}

The naming suffix CommandHandler is explicit about the CQRS role. The handler changes state and returns only the data needed to confirm the operation succeeded.

Building the Query Side

A query handler reads state. It receives a query object describing what data is needed and returns that data. It never changes anything.

// Features/Tasks/GetTasks/GetTasksQuery.cs
namespace TaskTracker.Features.Tasks.GetTasks;

public sealed record GetTasksQuery(
    Guid ProjectId,
    bool? IsCompleted = null,
    string? TitleContains = null);
// Features/Tasks/GetTasks/GetTasksResult.cs
namespace TaskTracker.Features.Tasks.GetTasks;

public sealed record GetTasksResult(IReadOnlyList<TaskSummary> Tasks, int TotalCount);

public sealed record TaskSummary(
    Guid Id,
    string Title,
    bool IsCompleted,
    DateTimeOffset CreatedAt,
    DateTimeOffset? DueDate);
// Features/Tasks/GetTasks/GetTasksQueryHandler.cs
namespace TaskTracker.Features.Tasks.GetTasks;

public sealed class GetTasksQueryHandler
{
    private readonly AppDbContext _db;

    public GetTasksQueryHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<GetTasksResult> HandleAsync(
        GetTasksQuery query,
        CancellationToken cancellationToken = default)
    {
        var tasksQuery = _db.Tasks
            .AsNoTracking()                          // read-only: no change tracking needed
            .Where(t => t.ProjectId == query.ProjectId)
            .AsQueryable();

        if (query.IsCompleted.HasValue)
        {
            tasksQuery = tasksQuery.Where(t => t.IsCompleted == query.IsCompleted.Value);
        }

        if (!string.IsNullOrWhiteSpace(query.TitleContains))
        {
            tasksQuery = tasksQuery.Where(t =>
                t.Title.Contains(query.TitleContains));
        }

        var tasks = await tasksQuery
            .OrderByDescending(t => t.CreatedAt)
            .Select(t => new TaskSummary(t.Id, t.Title, t.IsCompleted, t.CreatedAt, t.DueDate))
            .ToListAsync(cancellationToken);

        return new GetTasksResult(tasks, tasks.Count);
    }
}

Note AsNoTracking() on the query. When materializing entity objects in a read-only scenario, change tracking serves no purpose and consumes memory. If you project into a DTO with .Select(...) instead of materializing entities, EF Core does not track anything anyway -- AsNoTracking() matters specifically when you return entity instances. Using it consistently on query handlers makes the intent clear: this handler reads but never writes.

Wiring Commands and Queries to Endpoints

Each endpoint wires one HTTP route to one handler -- one command handler or one query handler:

// Features/Tasks/CreateTask/CreateTaskEndpoint.cs
namespace TaskTracker.Features.Tasks.CreateTask;

public static class CreateTaskEndpoint
{
    public static void Map(IEndpointRouteBuilder routes)
    {
        routes.MapPost("/tasks", async (
            CreateTaskCommand command,
            CreateTaskCommandHandler handler,
            CancellationToken cancellationToken) =>
        {
            var result = await handler.HandleAsync(command, cancellationToken);
            return Results.Created($"/tasks/{result.TaskId}", result);
        })
        .WithName("CreateTask")
        .WithTags("Tasks");
    }
}
// Features/Tasks/GetTasks/GetTasksEndpoint.cs
namespace TaskTracker.Features.Tasks.GetTasks;

public static class GetTasksEndpoint
{
    public static void Map(IEndpointRouteBuilder routes)
    {
        routes.MapGet("/tasks", async (
            [AsParameters] GetTasksQuery query,
            GetTasksQueryHandler handler,
            CancellationToken cancellationToken) =>
        {
            var result = await handler.HandleAsync(query, cancellationToken);
            return Results.Ok(result);
        })
        .WithName("GetTasks")
        .WithTags("Tasks");
    }
}

The [AsParameters] attribute on GetTasksQuery binds the query string parameters to the record properties automatically.

The CQRS Benefit in Feature Slices: Separate Read and Write Models

One of the most practical benefits of applying CQRS with feature slices is that commands and queries can have different views of the same data. A command needs the minimum data to perform an operation. A query returns exactly what the caller needs, nothing more and nothing less.

Here is an example where a complex read needs a different model than the write:

// Features/Tasks/GetTaskDashboard/GetTaskDashboardQueryHandler.cs
// This query joins tasks, projects, and users for a dashboard view.
// It would be wrong to use the same model as CreateTask.

namespace TaskTracker.Features.Tasks.GetTaskDashboard;

public sealed class GetTaskDashboardQueryHandler
{
    private readonly AppDbContext _db;

    public GetTaskDashboardQueryHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<GetTaskDashboardResult> HandleAsync(
        GetTaskDashboardQuery query,
        CancellationToken cancellationToken = default)
    {
        // Complex join projection -- only possible because this handler
        // is not constrained by the command-side write model
        var tasks = await _db.Tasks
            .AsNoTracking()
            .Where(t => t.ProjectId == query.ProjectId && !t.IsCompleted)
            .Join(
                _db.Users,
                t => t.AssignedToUserId,
                u => u.Id,
                (t, u) => new TaskDashboardItem(
                    t.Id,
                    t.Title,
                    t.DueDate,
                    u.DisplayName,
                    t.DueDate.HasValue && t.DueDate < DateTimeOffset.UtcNow))
            .OrderBy(t => t.DueDate)
            .ToListAsync(cancellationToken);

        var overdueCount = tasks.Count(t => t.IsOverdue);

        return new GetTaskDashboardResult(tasks, overdueCount);
    }
}

This query handler reads from the same database as the CreateTaskCommandHandler, but its read model is purpose-built for the dashboard view. The write model does not need to change when the dashboard adds a new field. The query model does not constrain the write model.

This is where CQRS with feature slices delivers real structural value beyond clean organization.

When to Split Read and Write Databases

In advanced CQRS architectures, commands write to a primary (write) database and queries read from a separate read replica or read-optimized store. Feature slices support this without structural changes -- just configure the query handlers to use a different database context:

// Program.cs - separate read and write contexts
builder.Services.AddDbContext<WriteDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Write")));

builder.Services.AddDbContext<ReadDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("ReadReplica")));

Command handlers receive WriteDbContext. Query handlers receive ReadDbContext. The feature slice structure does not need to change -- you register each handler with the appropriate context.

This is an advanced optimization worth exploring when query performance becomes a bottleneck, but the feature slice structure enables it from day one without requiring a refactor. Before committing to split databases, weigh the consistency tradeoffs: a read replica introduces replication lag, so a query-after-write scenario (write a record, then immediately query for it) may not see the new data if the read request hits the replica before replication completes. For most applications, a single database with AsNoTracking() on query handlers is the right starting point.

Connecting CQRS to Vertical Slice Architecture

The concept of CQRS per feature is central to what is commonly called vertical slice architecture in C#. Each slice handles one user interaction -- one command or one query. The slice is self-contained, and the CQRS distinction within each slice is explicit.

For teams already using MediatR with vertical slices, how to master vertical slice architecture techniques and examples covers the pattern in depth, including how IRequest<T> maps to the command/query distinction used here.

The CQRS pattern in C# and clean architecture is a natural companion to this article, showing how CQRS looks in a layered project structure vs. the feature-sliced approach shown here.

For the ASP.NET Core project template that puts these ideas together, ASP.NET Vertical Slice Project Template is a useful starting scaffold.

Frequently Asked Questions

What is CQRS with feature slices in C#?

CQRS with feature slices means organizing your .NET project by business feature (one folder per use case) and applying the command/query separation within each feature. Commands live in folders like CreateTask/ and CompleteTask/. Queries live in folders like GetTask/ and GetTasks/. Each handler is clearly either a command handler (changes state) or a query handler (reads state).

Do I need MediatR to use CQRS with feature slices?

No. MediatR is a dispatch mechanism that makes CQRS handler registration convenient, but it is not required. Plain command handler classes and query handler classes injected through ASP.NET Core's DI container implement CQRS without any dispatch framework. The naming convention (CommandHandler vs QueryHandler) carries the CQRS intent.

How is CQRS different from just having separate service methods?

A traditional service class like TaskService might have methods for creating, completing, and getting tasks all in one class. CQRS separates commands and queries into distinct handler classes in distinct feature folders. The difference is one of granularity and ownership -- each use case has its own class rather than being a method in a shared service.

Should commands return data in a CQRS with feature slices approach?

Commands typically return the minimum data needed to confirm the operation: the ID of the created entity, a success/failure indicator, or an error description. Returning a full response object from a command is acceptable when the caller needs it (e.g., to redirect to the created resource). What commands should not return is a rich read model -- that is what queries are for.

What is the benefit of AsNoTracking() in query handlers?

AsNoTracking() tells EF Core not to track the returned entities for change detection. Since query handlers never call SaveChangesAsync, change tracking serves no purpose and consumes memory and CPU. Adding AsNoTracking() to all query handlers is a small optimization that adds up at scale.

How does CQRS with feature slices scale to large projects?

CQRS with feature slices scales well because each feature is independent. Adding the hundredth feature is as straightforward as adding the second -- create a folder, add a few files, register the handler. The command/query separation ensures that complex read requirements never force changes to write models.

Can I use CQRS with feature slices alongside Entity Framework Core?

Yes. Command handlers use a single AppDbContext with change tracking. Query handlers use AsNoTracking() for performance. If you need separate read and write databases, configure a WriteDbContext and ReadDbContext and inject each into the appropriate handler type. The feature slice structure supports this without any restructuring.

Feature Slicing vs Clean Architecture in C#: Which One Should You Use?

Compare feature slicing vs clean architecture in C#. Learn the tradeoffs, when each approach fits best, and how to combine them in .NET projects.

Feature Slicing in C#: Organizing Code by Feature

Learn what feature slicing is in C# and how to organize your .NET projects by feature instead of by layer. Practical examples using a task app, no MediatR required.

Feature Slicing Without MediatR in C#: Plain Handlers That Actually Work

Learn how to implement feature slicing in C# without MediatR. Build clean, testable feature handlers using plain classes and ASP.NET Core Minimal APIs.

An error has occurred. This application may no longer respond until reloaded. Reload