.NET  

Modern Backend Architecture in .NET – Implementing Vertical Slice + CQRS

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

  1. How the Project Is Organized

  2. How Commands Is Implemented

  3. Command, Handler, and Validator by Example

  4. How Queries Is Implemented

  5. Todo Endpoints

  6. How Validators Are Implemented

  7. How the Request Flows Through the Code

  8. Layered vs Vertical Slice: Same Feature, Proved in Code

  9. Summary: One Slice, One Request Type

1. How the Project Is Organized

The solution is split into a few clear areas:

Folder / FileRole
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

WhatWhere it livesWhat it is
Entities (table definitions)Core/Classes that map to database tables. Used by EF Core and by handlers that write data.
TodoCore/Todo.csThe 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.
CreateTodoRequestFeatures/Todos/TodoEndpoints.csRequest DTO for POST /api/todos: Title, Description.
UpdateTodoRequestFeatures/Todos/TodoEndpoints.csRequest DTO for PUT /api/todos/{id}: optional Title, Description, IsCompleted.
Result DTOs (what we return from a command/query)Inside each sliceRecords that define the response shape for that use case. Defined in the same file as the Command or Query.
CreateTodoResultFeatures/Todos/CreateTodo/CreateTodoCommand.csResult of create: Id, Title.
GetTodoResultFeatures/Todos/GetTodo/GetTodoQuery.csResult of get-one: Id, Title, Description, IsCompleted, CreatedAt.
GetTodosResult, GetTodosItemFeatures/Todos/GetTodos/GetTodosQuery.csResult of get-all: a list of GetTodosItem (same shape as one row).
UpdateTodoResultFeatures/Todos/UpdateTodo/UpdateTodoCommand.csResult of update: Id, Title, IsCompleted.
DeleteNo 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

  1. CreateTodo (folder: Features/Todos/CreateTodo/ ):

  • CreateTodoCommand: the request: Title , Description . Implements IRequest<CreateTodoResult> .

  
    // 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);
    }
}
  
  • CreateTodoValidator: FluentValidation rules (e.g. Title required, max length). Used by the validation pipeline before the handler.

  
    // 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");
    }
}
  
  1. UpdateTodo (folder: Features/Todos/UpdateTodo/ ):

  • UpdateTodoCommand: Id plus optional Title , Description , IsCompleted . Implements IRequest<UpdateTodoResult?> .

  
 //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);
  
  • UpdateTodoHandler: loads the todo, applies only the provided fields, saves. Returns null if the todo doesn’t exist.

  
    //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);
    }
}
  1. DeleteTodo (folder: Features/Todos/DeleteTodo/ ):

  • DeleteTodoCommand : Id . Implements IRequest<bool> .

  
//Features/Todos/DeleteTodo/DeleteTodoCommand .cs

public record DeleteTodoCommand(int Id) : IRequest<bool>;
  
  • DeleteTodoHandler: finds the todo, removes it, saves. Returns true if deleted, false if not found.

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

  • GetTodoQuery : Id. Implements IRequest<GetTodoResult?>.

//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);
  • GetTodoHandler: loads the todo with AsNoTracking(), maps to GetTodoResult, or returns null if not found.

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

  • GetTodosQuery: optional filter IsCompleted. Implements IRequest<GetTodosResult>.

//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);
  • GetTodosHandler: builds a query (optionally filtered), projects to GetTodosItem, returns GetTodosResult(Items).

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

  1. Takes the HTTP input (route params, query string, body).

  2. Builds one Command or Query.

  3. Calls mediator.Send(commandOrQuery).

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

  1. Register all validators from the assembly (so MediatR can resolve them):

    builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
  2. Register MediatR and its handlers:

    builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
  3. 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:

  1. Client sends POST with invalid data (e.g. empty title).

  2. Endpoint builds CreateTodoCommand and calls mediator.Send(command).

  3. MediatR runs ValidationBehavior before CreateTodoHandler.

  4. CreateTodoValidator runs and returns errors.

  5. ValidationBehavior throws ValidationException.

  6. Exception handler catches it and returns 400 with { errors: [ { propertyName, errorMessage }, ... ] }.

  7. 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)

  1. HTTP: POST /api/todos with body { "title": "My task", "description": "Optional" }.

  2. TodoEndpoints: MapPost("/", ...) runs. Model binding gives CreateTodoRequest. Endpoint creates CreateTodoCommand("My task", "Optional") and calls await mediator.Send(command).

  3. MediatR: Finds the pipeline for IRequest<CreateTodoResult>. Runs ValidationBehavior first.

  4. ValidationBehavior: Finds CreateTodoValidator, runs it. Here the command is valid, so no exception. Then calls next().

  5. CreateTodoHandler: Runs Handle(CreateTodoCommand). Creates Todo, adds to _db.Todos, SaveChangesAsync(), returns CreateTodoResult(Id, Title).

  6. MediatR: Returns that result to the endpoint.

  7. TodoEndpoints: Returns Results.Created($"/api/todos/{result.Id}", result).

  8. 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)

  1. HTTP: GET /api/todos/1.

  2. TodoEndpoints: MapGet("/{id:int}", ...) runs with id = 1. Calls await mediator.Send(new GetTodoQuery(1)).

  3. MediatR: Runs ValidationBehavior. There is no validator for GetTodoQuery, so it calls next() immediately.

  4. GetTodoHandler: Runs Handle(GetTodoQuery). Loads the todo with _db.Todos.AsNoTracking(), maps to GetTodoResult, returns it (or null).

  5. MediatR: Returns the result to the endpoint.

  6. TodoEndpoints: Returns Results.Ok(result) or Results.NotFound().

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

ConceptIn this project
Vertical sliceOne folder per use case under Features/Todos/ (CreateTodo, GetTodo, GetTodos, UpdateTodo, DeleteTodo). Each slice has Command/Query + Handler (+ Validator when needed).
CQRSCommands (Create, Update, Delete) and Queries (Get, GetTodos) as IRequest<T>. One handler per request type. Endpoints only call mediator.Send(...).
ValidatorsFluentValidation 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.
HTTPTodoEndpoints.cs maps routes to Commands/Queries and MediatR. Exception handler turns ValidationException into 400 with error list.

Get code here: vertical-cqrs-architecture-dotnet