This is Article 2 in the series. Article 1 - Modern Backend Architecture in .NET - Feature-First Design gives the overall picture of Vertical Slice Architecture and CQRS. Here, we focus on this project’s code: how the Todo API is structured and how validators, CQRS, and vertical slices are implemented in practice.
Get code here: vertical-cqrs-architecture-dotnet
Table of Contents
How the Project Is Organized
How Commands Is Implemented
Command, Handler, and Validator by Example
How Queries Is Implemented
Todo Endpoints
How Validators Are Implemented
How the Request Flows Through the Code
Layered vs Vertical Slice: Same Feature, Proved in Code
Summary: One Slice, One Request Type
1. How the Project Is Organized
The solution is split into a few clear areas:
| Folder / File | Role |
|---|
| Core/ | Shared domain types used across features (e.g. the Todo entity). |
| Infrastructure/ | Database (AppDbContext), and the MediatR |
| Features/Todos/ | Everything for the “Todos” feature: one place per use case (Create, Get, GetAll, Update, Delete). |
Each use case is a vertical slice : its own folder with Command or Query, Handler, and optional Validator. The feature also has a single TodoEndpoints.cs where all HTTP routes for Todos are mapped.
Entities and DTOs in this project
| What | Where it lives | What it is |
|---|
| Entities (table definitions) | Core/ | Classes that map to database tables. Used by EF Core and by handlers that write data. |
| Todo | Core/Todo.cs | The only entity in this project. Represents the Todos table (Id, Title, Description, IsCompleted, CreatedAt). |
| Request DTOs (what the client sends) | Features/Todos/ | Records that model the HTTP request body or query string for a given endpoint. |
| CreateTodoRequest | Features/Todos/TodoEndpoints.cs | Request DTO for POST /api/todos: Title, Description. |
| UpdateTodoRequest | Features/Todos/TodoEndpoints.cs | Request DTO for PUT /api/todos/{id}: optional Title, Description, IsCompleted. |
| Result DTOs (what we return from a command/query) | Inside each slice | Records that define the response shape for that use case. Defined in the same file as the Command or Query. |
| CreateTodoResult | Features/Todos/CreateTodo/CreateTodoCommand.cs | Result of create: Id, Title. |
| GetTodoResult | Features/Todos/GetTodo/GetTodoQuery.cs | Result of get-one: Id, Title, Description, IsCompleted, CreatedAt. |
| GetTodosResult, GetTodosItem | Features/Todos/GetTodos/GetTodosQuery.cs | Result of get-all: a list of GetTodosItem (same shape as one row). |
| UpdateTodoResult | Features/Todos/UpdateTodo/UpdateTodoCommand.cs | Result of update: Id, Title, IsCompleted. |
| Delete | — | No result DTO; the handler returns bool (true/false). |
Core: The Todo Entity
The only shared domain model in this project is Todo :
// Core/Todo.cs
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
Handlers that write (Create, Update, Delete) use this entity with AppDbContext . Handlers that read (Get, GetTodos) often return small result types (e.g. GetTodoResult ) instead of exposing the entity directly. That keeps the API contract clear and fits CQRS: reads can have their own shape.
Infrastructure: DbContext and Validation Pipeline
AppDbContext ( Infrastructure/Data/AppDbContext.cs ): EF Core context with a Todos DbSet and InMemory provider. No real database is required.
ValidationBehavior ( Infrastructure/ValidationBehavior.cs ): A MediatR pipeline behavior that runs all validators for the current request before the handler. If validation fails, it throws ValidationException and the handler is never called. This is how “validator + CQRS” work together: validation is applied to commands (or any request) in one place, without putting validation logic inside handlers.
2. How Commands Is Implemented
Commands = “do something that changes state” (Create, Update, Delete). Each is a class/record that implements IRequest<TResponse> .
Queries = “return data, don’t change state” (Get one, Get list). Same idea: a class/record implementing IRequest<TResponse> .
MediatR sends the request to the one handler that matches that request type. So: one Command/Query type → one Handler.
Commands in This Project
CreateTodo (folder: Features/Todos/CreateTodo/ ):
// Features/Todos/CreateTodo/CreateTodoCommand.cs
namespace TodoApi.Features.Todos.CreateTodo;
public record CreateTodoCommand(string Title, string? Description) : IRequest<CreateTodoResult>;
public record CreateTodoResult(int Id, string Title);
CreateTodoHandler: creates a Todo , adds it via AppDbContext , saves, returns CreateTodoResult(Id, Title)
// Features/Todos/CreateTodo/CreateTodoHandler .cs
public class CreateTodoHandler(AppDbContext db) : IRequestHandler<CreateTodoCommand, CreateTodoResult>
{
private readonly AppDbContext _db = db;
public async Task<CreateTodoResult> Handle(CreateTodoCommand request, CancellationToken cancellationToken)
{
var todo = new Todo
{
Title = request.Title,
Description = request.Description,
IsCompleted = false
};
_db.Todos.Add(todo);
await _db.SaveChangesAsync(cancellationToken);
return new CreateTodoResult(todo.Id, todo.Title);
}
}
// Features/Todos/CreateTodo/CreateTodoValidator.cs
public class CreateTodoValidator : AbstractValidator<CreateTodoCommand>
{
public CreateTodoValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required")
.MaximumLength(200).WithMessage("Title must be at most 200 characters");
}
}
UpdateTodo (folder: Features/Todos/UpdateTodo/ ):
//Features/Todos/UpdateTodo/UpdateTodoCommand .cs
public record UpdateTodoCommand(
int Id,
string? Title,
string? Description,
bool? IsCompleted
) : IRequest<UpdateTodoResult?>;
public record UpdateTodoResult(int Id, string Title, bool IsCompleted);
//Features/Todos/UpdateTodo/UpdateTodoHandler .cs
public class UpdateTodoHandler(AppDbContext db) : IRequestHandler<UpdateTodoCommand, UpdateTodoResult?>
{
private readonly AppDbContext _db = db;
public async Task<UpdateTodoResult?> Handle(UpdateTodoCommand request, CancellationToken cancellationToken)
{
var todo = await _db.Todos.FindAsync([request.Id], cancellationToken);
if (todo is null)
return null;
if (request.Title is { } title)
todo.Title = title;
if (request.Description is { } desc)
todo.Description = desc;
if (request.IsCompleted.HasValue)
todo.IsCompleted = request.IsCompleted.Value;
await _db.SaveChangesAsync(cancellationToken);
return new UpdateTodoResult(todo.Id, todo.Title, todo.IsCompleted);
}
}
DeleteTodo (folder: Features/Todos/DeleteTodo/ ):
//Features/Todos/DeleteTodo/DeleteTodoCommand .cs
public record DeleteTodoCommand(int Id) : IRequest<bool>;
//Features/Todos/DeleteTodo/DeleteTodoHandler.cs
public class DeleteTodoHandler(AppDbContext db) : IRequestHandler<DeleteTodoCommand, bool>
{
private readonly AppDbContext _db = db;
public async Task<bool> Handle(DeleteTodoCommand request, CancellationToken cancellationToken)
{
var todo = await _db.Todos.FindAsync([request.Id], cancellationToken);
if (todo is null)
return false;
_db.Todos.Remove(todo);
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}
So for writes , we have one command type and one handler per use case. No “god service”; each handler is small and focused.
3.Command, Handler, and Validator by Example
1. Command : The request message: what to do, no logic. It implements IRequest<TResponse> so MediatR can route it.
// Features/Todos/CreateTodo/CreateTodoCommand.cs
public record CreateTodoCommand(string Title, string? Description) : IRequest<CreateTodoResult>;
public record CreateTodoResult(int Id, string Title);
The command carries only the data (Title, Description). The result type CreateTodoResult says what the handler will return (Id and Title). The endpoint builds this command from the HTTP body and sends it via mediator.Send(command).
2. Handler: The logic: do the work. It receives the command, uses the database, and returns the result.
// Features/Todos/CreateTodo/CreateTodoHandler.cs
public class CreateTodoHandler : IRequestHandler<CreateTodoCommand, CreateTodoResult>
{
private readonly AppDbContext _db;
public async Task<CreateTodoResult> Handle(CreateTodoCommand request, CancellationToken cancellationToken)
{
var todo = new Todo
{
Title = request.Title,
Description = request.Description,
IsCompleted = false
};
_db.Todos.Add(todo);
await _db.SaveChangesAsync(cancellationToken);
return new CreateTodoResult(todo.Id, todo.Title);
}
}
The handler creates a Todo entity, adds it to the DbContext, saves, and returns a CreateTodoResult. No validation here, that happens before the handler runs.
3. Validator: Check input before the handler runs. If validation fails, the handler is never called.
// Features/Todos/CreateTodo/CreateTodoValidator.cs
public class CreateTodoValidator : AbstractValidator<CreateTodoCommand>
{
public CreateTodoValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required")
.MaximumLength(200).WithMessage("Title must be at most 200 characters");
}
}
The validator runs on the same CreateTodoCommand. It ensures Title is not empty and is at most 200 characters. When you use the validation pipeline (see "How Validators Are Implemented" in this article), this runs first; if there are errors, MediatR throws ValidationException and the handler is skipped.
So in one slice: Command = data in; Handler = work; Validator = input check before work.
The validator runs on the same CreateTodoCommand. It ensures Title is not empty and is at most 200 characters. When you use the validation pipeline (see "How Validators Are Implemented" in this article), this runs first; if there are errors, MediatR throws ValidationException and the handler is skipped.
So in one slice: Command = data in; Handler = work; Validator = input check before work.
4. How Queries Is Implemented
GetTodo (folder: Features/Todos/GetTodo/):
//Features/Todos/GetTodo/GetTodoQuery .cs
public record GetTodoQuery(int Id) : IRequest<GetTodoResult?>;
public record GetTodoResult(int Id, string Title, string? Description, bool IsCompleted, DateTime CreatedAt);
//Features/Todos/GetTodo/GetTodoHandler.cs
public class GetTodoHandler(AppDbContext db) : IRequestHandler<GetTodoQuery, GetTodoResult?>
{
private readonly AppDbContext _db = db;
public async Task<GetTodoResult?> Handle(GetTodoQuery request, CancellationToken cancellationToken)
{
var todo = await _db.Todos
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == request.Id, cancellationToken);
if (todo is null)
return null;
return new GetTodoResult(
todo.Id,
todo.Title,
todo.Description,
todo.IsCompleted,
todo.CreatedAt
);
}
}
GetTodos (folder: Features/Todos/GetTodos/):
//Features/Todos/GetTodos/GetTodosQuery.cs
public record GetTodosQuery(bool? IsCompleted = null) : IRequest<GetTodosResult>;
public record GetTodosResult(IReadOnlyList<GetTodosItem> Items);
public record GetTodosItem(int Id, string Title, string? Description, bool IsCompleted, DateTime CreatedAt);
//Features/Todos/GetTodos/GetTodosHandler.cs
public class GetTodosHandler(AppDbContext db) : IRequestHandler<GetTodosQuery, GetTodosResult>
{
private readonly AppDbContext _db = db;
public async Task<GetTodosResult> Handle(GetTodosQuery request, CancellationToken cancellationToken)
{
var query = _db.Todos.AsNoTracking();
if (request.IsCompleted.HasValue)
query = query.Where(t => t.IsCompleted == request.IsCompleted.Value);
var items = await query
.OrderBy(t => t.CreatedAt)
.Select(t => new GetTodosItem(t.Id, t.Title, t.Description, t.IsCompleted, t.CreatedAt))
.ToListAsync(cancellationToken);
return new GetTodosResult(items);
}
}
So for reads, we have one query type and one handler per use case. Read models (e.g. GetTodoResult, GetTodosItem) are defined next to the query in the same slice.
5. Todo Endpoints
All Todo HTTP routes are in TodoEndpoints.cs. Each route:
Takes the HTTP input (route params, query string, body).
Builds one Command or Query.
Calls mediator.Send(commandOrQuery).
Returns the result (e.g. Ok, Created, NotFound, NoContent).
Example for create (POST):
group.MapPost("/", async (CreateTodoRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new CreateTodoCommand(request.Title, request.Description));
return Results.Created($"/api/todos/{result.Id}", result);
});
Example for get one (GET):
group.MapGet("/{id:int}", async (int id, IMediator mediator) =>
{
var result = await mediator.Send(new GetTodoQuery(id));
return result is null ? Results.NotFound() : Results.Ok(result);
});
Entire TodoEndPoints file
//Features/Todos/TodoEndpoints.cs
public static class TodoEndpoints
{
public static void MapTodoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/todos").WithTags("Todos");
// GET /api/todos -> Get all todos (optional ?isCompleted=true|false)
group.MapGet("/", async (bool? isCompleted, IMediator mediator) =>
{
var result = await mediator.Send(new GetTodosQuery(isCompleted));
return Results.Ok(result);
});
// GET /api/todos/{id} -> Get one todo
group.MapGet("/{id:int}", async (int id, IMediator mediator) =>
{
var result = await mediator.Send(new GetTodoQuery(id));
return result is null ? Results.NotFound() : Results.Ok(result);
});
// POST /api/todos -> Create a todo
group.MapPost("/", async (CreateTodoRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new CreateTodoCommand(request.Title, request.Description));
return Results.Created($"/api/todos/{result.Id}", result);
});
// PUT /api/todos/{id} -> Update a todo
group.MapPut("/{id:int}", async (int id, UpdateTodoRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new UpdateTodoCommand(
id, request.Title, request.Description, request.IsCompleted));
return result is null ? Results.NotFound() : Results.Ok(result);
});
// DELETE /api/todos/{id}
group.MapDelete("/{id:int}", async (int id, IMediator mediator) =>
{
var deleted = await mediator.Send(new DeleteTodoCommand(id));
return deleted ? Results.NoContent() : Results.NotFound();
});
}
// Request DTOs for POST/PUT (what the client sends in the body)
public record CreateTodoRequest(string Title, string? Description);
public record UpdateTodoRequest(string? Title, string? Description, bool? IsCompleted);
}
6. How Validators Are Implemented
Validators are used to check input before the handler runs. In this project we use FluentValidation and plug it into MediatR with a pipeline behavior. Only some requests have validators (we use one for CreateTodo); the pattern is the same for any command you want to validate.
Step 1: Define a Validator (e.g. CreateTodo)
The validator is in the same slice as the command: Features/Todos/CreateTodo/CreateTodoValidator.cs:
public class CreateTodoValidator : AbstractValidator<CreateTodoCommand>
{
public CreateTodoValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required")
.MaximumLength(200).WithMessage("Title must be at most 200 characters");
}
}
So: one validator per command (when needed), in the same feature folder. This keeps the “create todo” behavior (command + validation + handler) in one vertical slice.
Step 2: Register Validators and the Validation Pipeline (Program.cs)
In Program.cs we do three things:
Register all validators from the assembly (so MediatR can resolve them):
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
Register MediatR and its handlers:
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
Register the validation pipeline behavior so it runs for every request that has validators:
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Every time MediatR handles a request, it first runs ValidationBehavior. That behavior looks for any IValidator<TRequest> for that request type. If there are validators (e.g. for CreateTodoCommand), it runs them; if there are none (e.g. for GetTodoQuery), it just calls the handler.
Step 3: ValidationBehavior: Run Validators Before the Handler
The behavior is in Infrastructure/ValidationBehavior.cs:
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = results.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next(); // only if validation passed
}
}
No validators for this request type → call next() (the handler).
Validators exist → run them all, collect errors. If any errors → throw ValidationException. If none → call next().
So validators are part of the CQRS pipeline: they sit in front of the handler and protect it from invalid input. The handler can assume that when it runs, the command has already been validated.
Step 4: Turn ValidationException into 400 (Program.cs)
In Program.cs we use the ASP.NET Core exception handler to convert ValidationException into a 400 Bad Request with a JSON body of errors:
app.UseExceptionHandler(errApp =>
{
errApp.Run(async context =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (ex is ValidationException validationEx)
{
context.Response.StatusCode = 400;
context.Response.ContentType = "application/json";
var errors = validationEx.Errors.Select(e => new { e.PropertyName, e.ErrorMessage });
await context.Response.WriteAsJsonAsync(new { errors });
return;
}
// ... other exceptions → 500
});
});
So the validator + CQRS flow is:
Client sends POST with invalid data (e.g. empty title).
Endpoint builds CreateTodoCommand and calls mediator.Send(command).
MediatR runs ValidationBehavior before CreateTodoHandler.
CreateTodoValidator runs and returns errors.
ValidationBehavior throws ValidationException.
Exception handler catches it and returns 400 with { errors: [ { propertyName, errorMessage }, ... ] }.
CreateTodoHandler is never called.
7. How the Request Flows Through the Code
Here we trace one create request and one read request so you see how the project code fits together.
Example 1: POST /api/todos (Create — with validator)
HTTP: POST /api/todos with body { "title": "My task", "description": "Optional" }.
TodoEndpoints: MapPost("/", ...) runs. Model binding gives CreateTodoRequest. Endpoint creates CreateTodoCommand("My task", "Optional") and calls await mediator.Send(command).
MediatR: Finds the pipeline for IRequest<CreateTodoResult>. Runs ValidationBehavior first.
ValidationBehavior: Finds CreateTodoValidator, runs it. Here the command is valid, so no exception. Then calls next().
CreateTodoHandler: Runs Handle(CreateTodoCommand). Creates Todo, adds to _db.Todos, SaveChangesAsync(), returns CreateTodoResult(Id, Title).
MediatR: Returns that result to the endpoint.
TodoEndpoints: Returns Results.Created($"/api/todos/{result.Id}", result).
HTTP: Client gets 201 Created and the new todo data.
If the body had "title": "", step 4 would throw ValidationException, the exception handler would return 400 with error messages, and the handler would never run.
Example 2: GET /api/todos/1 (Read — no validator)
HTTP: GET /api/todos/1.
TodoEndpoints: MapGet("/{id:int}", ...) runs with id = 1. Calls await mediator.Send(new GetTodoQuery(1)).
MediatR: Runs ValidationBehavior. There is no validator for GetTodoQuery, so it calls next() immediately.
GetTodoHandler: Runs Handle(GetTodoQuery). Loads the todo with _db.Todos.AsNoTracking(), maps to GetTodoResult, returns it (or null).
MediatR: Returns the result to the endpoint.
TodoEndpoints: Returns Results.Ok(result) or Results.NotFound().
HTTP: Client gets 200 OK with the todo JSON or 404 Not Found.
So in code: Endpoint → MediatR → (ValidationBehavior → Validator if any) → Handler → DB (if needed) → back to Endpoint → HTTP response.
8. Layered vs Vertical Slice: Same Feature, Proved in Code
Layered (controller → service → repository)
Code for "create todo" is spread across three layers and three folders:
1. Controller (e.g. Controllers/TodosController.cs) — receives HTTP, calls service:
[ApiController]
[Route("api/[controller]")]
public class TodosController : ControllerBase
{
private readonly ITodoService _todoService;
public TodosController(ITodoService todoService) => _todoService = todoService;
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTodoRequest request)
{
var result = await _todoService.CreateTodoAsync(request.Title, request.Description);
return Created($"/api/todos/{result.Id}", result);
}
}
2. Service (e.g. Services/TodoService.cs) — business logic, calls repository:
public class TodoService : ITodoService
{
private readonly ITodoRepository _repo;
public async Task<CreateTodoResult> CreateTodoAsync(string title, string? description)
{
var todo = new Todo { Title = title, Description = description, IsCompleted = false };
await _repo.AddAsync(todo);
return new CreateTodoResult(todo.Id, todo.Title);
}
}
3. Repository (e.g. Repositories/TodoRepository.cs) — data access:
public class TodoRepository : ITodoRepository
{
private readonly AppDbContext _db;
public async Task AddAsync(Todo todo) { _db.Todos.Add(todo); await _db.SaveChangesAsync(); }
}
To change or debug "create todo" you open Controller → Service → Repository (and possibly a validator in yet another place).
This project (Vertical Slice + CQRS)
The same behavior lives in one slice. The endpoint does not call a service or repository; it only sends a command. The handler does what the service + repository did.
1. Endpoint (Features/Todos/TodoEndpoints.cs) — build command, send to MediatR:
group.MapPost("/", async (CreateTodoRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new CreateTodoCommand(request.Title, request.Description));
return Results.Created($"/api/todos/{result.Id}", result);
});
2. Command + Handler (both in Features/Todos/CreateTodo/) — request and logic in one place:
CreateTodoCommand.cs — the message (Title, Description).
CreateTodoHandler.cs — creates Todo, adds to _db.Todos, saves, returns result (no separate repository; the handler uses AppDbContext directly).
3. Validator (same folder) — CreateTodoValidator.cs — runs before the handler.
So: one folder, one use case. To change or debug "create todo" you go to Features/Todos/CreateTodo/ and everything is there. No jumping between Controllers, Services, and Repositories. That’s the difference the code proves.
Summary
| Concept | In this project |
|---|
| Vertical slice | One folder per use case under Features/Todos/ (CreateTodo, GetTodo, GetTodos, UpdateTodo, DeleteTodo). Each slice has Command/Query + Handler (+ Validator when needed). |
| CQRS | Commands (Create, Update, Delete) and Queries (Get, GetTodos) as IRequest<T>. One handler per request type. Endpoints only call mediator.Send(...). |
| Validators | FluentValidation validators in the same slice as the command (e.g. CreateTodoValidator). Registered in DI; ValidationBehavior runs them before the handler and throws ValidationException on failure. |
| HTTP | TodoEndpoints.cs maps routes to Commands/Queries and MediatR. Exception handler turns ValidationException into 400 with error list. |
Get code here: vertical-cqrs-architecture-dotnet