# VSA Handler Patterns — Complete Feature Slice Guide

> **Scope:** dotnet-vsa
> **Layer:** 0 (always load)
> **Keywords:** vsa, handler, feature-slice, vertical-slice, minimal-api, endpoint
> **Load When:** dotnet VSA projects

**Verified against:** .NET 10 (minimal APIs, primary constructors, `Guid.CreateVersion7()`). Last-verified: 2026-05-20.

---

## Overview

Each feature in VSA is a self-contained slice with 5 files in a feature folder. Handlers implement `IHandler<TRequest, TResponse>` (not MediatR). Dependencies use primary constructors. The data path is `Handler -> ApplicationDbContext` — no service layer, no generic repository in between.

---

## IHandler\<TRequest, TResponse\>

A single-method interface — intentionally minimal:

```csharp
public interface IHandler<in TRequest, TResponse>
{
    Task<TResponse> HandleAsync(TRequest command, CancellationToken cancellationToken);
}
```

**Why not MediatR:** No `IRequest`/`INotification` marker interfaces, no `Send()` indirection, no service locator. Handlers are resolved directly from DI by their interface type. Decorators (validation, logging) are applied via Scrutor, not MediatR pipeline behaviors.

---

## Feature Folder Structure (5 Files Per Slice)

```
Features/
  BookFeature/
    CreateBook/
      CreateBookRequest.cs       (sealed record — can be inline in handler)
      CreateBookResponse.cs      (sealed record — can be inline in handler)
      CreateBookHandler.cs       (business logic)
      CreateBookValidator.cs     (FluentValidation)
      CreateBookEndpoint.cs      (minimal API mapping)
    GetBookById/
      ...
    UpdateBook/
      ...
    DeleteBook/
      ...
    BookErrors.cs                (shared error factory for the feature)
```

Request/Response DTOs can be defined inline in the handler file for simple slices:

```csharp
namespace ProjectName.Features.BookFeature.CreateBook;

// Sealed records for DTOs — immutable, structural equality
public sealed record CreateBookRequest(
    string Title, string Author, string ISBN, decimal Price, int PublishedYear);

public sealed record CreateBookResponse(
    Guid Id, string Title, string Author, string ISBN, decimal Price, int PublishedYear);
```

---

## Complete Handler Example

```csharp
namespace ProjectName.Features.BookFeature.CreateBook;

using ProjectName.Abstractions;
using ProjectName.Database;
using ProjectName.Entities;

public sealed record CreateBookRequest(
    string Title, string Author, string ISBN, decimal Price, int PublishedYear);

public sealed record CreateBookResponse(
    Guid Id, string Title, string Author, string ISBN, decimal Price, int PublishedYear);

// Primary constructor for DI — no field declarations needed
public sealed class CreateBookHandler(
    ApplicationDbContext db) : IHandler<CreateBookRequest, Result<CreateBookResponse>>
{
    public async Task<Result<CreateBookResponse>> HandleAsync(
        CreateBookRequest command, CancellationToken cancellationToken)
    {
        var book = new Book
        {
            Id = Guid.CreateVersion7(),   // .NET 9+ — time-sortable UUID
            Title = command.Title,
            Author = command.Author,
            ISBN = command.ISBN,
            Price = command.Price,
            PublishedYear = command.PublishedYear
        };

        db.Books.Add(book);
        await db.SaveChangesAsync(cancellationToken);

        return Result.Success(new CreateBookResponse(
            book.Id, book.Title, book.Author, book.ISBN, book.Price, book.PublishedYear));
    }
}
```

### Key Patterns

- **`sealed class`** — handlers are never inherited
- **`sealed record`** — DTOs are immutable value types
- **Primary constructors** — `(ApplicationDbContext db)` replaces field + constructor boilerplate
- **`Guid.CreateVersion7()`** — time-sortable, index-friendly UUID (not `Guid.NewGuid()`)
- **`CancellationToken`** — passed through every async call
- **`Result<T>` return** — never throw for expected failures

---

## Complete Endpoint Example

```csharp
namespace ProjectName.Features.BookFeature.CreateBook;

using ProjectName.Abstractions;
using ProjectName.Constants;
using ProjectName.Extensions;

internal sealed class CreateBookEndpoint : IApiEndpoint
{
    public void MapEndpoint(IEndpointRouteBuilder app)
    {
        app.MapPost("books", async (
            IHandler<CreateBookRequest, Result<CreateBookResponse>> handler,
            CreateBookRequest command,
            CancellationToken cancellationToken) =>
        {
            var result = await handler.HandleAsync(command, cancellationToken);
            return result.Match(
                onSuccess: () => Results.Ok(result.Value),
                onFailure: error => Results.BadRequest(error));
        })
        .WithTags(ApiTags.Books)
        .Produces<CreateBookResponse>(StatusCodes.Status200OK)
        .Produces(StatusCodes.Status400BadRequest);
    }
}
```

### Endpoint Conventions

- **`internal sealed`** — endpoints are not public API
- **`IApiEndpoint`** — single interface for auto-discovery
- **Handler injected via DI** — resolved as `IHandler<TReq, TRes>` parameter
- **`Match()` for responses** — never access `.Value` directly
- **`.WithTags()`** — always tag for Scalar API docs
- **`.Produces<T>()`** — always document response types

---

## IApiEndpoint Interface

```csharp
public interface IApiEndpoint
{
    void MapEndpoint(IEndpointRouteBuilder app);
}
```

All endpoint classes implementing this interface are auto-discovered and mapped via `MapApiEndpoints()` in Program.cs.

---

## Data Access: DbContext in the Slice (No Service Layer, No Generic Repository)

Handlers inject `ApplicationDbContext` directly. Over EF Core a generic repository is
redundant — `DbSet<T>` **is** the repository, `DbContext` **is** the unit of work. A
generic `IRepository<T>`/`IUnitOfWork` pair adds indirection with zero value and, in
audited real projects, ends up registered in DI with zero consumers (dead code).

CRUD by key goes straight to the `DbSet`:

```csharp
public sealed class DeleteBookHandler(
    ApplicationDbContext db) : IHandler<DeleteBookRequest, Result>
{
    public async Task<Result> HandleAsync(
        DeleteBookRequest command, CancellationToken cancellationToken)
    {
        var book = await db.Books.FindAsync([command.Id], cancellationToken);
        if (book is null)
            return BookErrors.NotFound(command.Id);

        db.Books.Remove(book);
        await db.SaveChangesAsync(cancellationToken);
        return Result.Success();
    }
}
```

Slice-specific queries — joins, projections, pagination, composite filters — are LINQ
written in the slice:

```csharp
public sealed class SearchBooksHandler(
    ApplicationDbContext db) : IHandler<SearchBooksRequest, Result<IReadOnlyList<BookDto>>>
{
    public async Task<Result<IReadOnlyList<BookDto>>> HandleAsync(
        SearchBooksRequest query, CancellationToken cancellationToken)
    {
        var books = await db.Books
            .Where(b => b.Author.Contains(query.Author))
            .OrderBy(b => b.Title)
            .Skip(query.Skip).Take(query.Take)
            .Select(b => new BookDto(b.Id, b.Title, b.Author))
            .ToListAsync(cancellationToken);

        return Result.Success<IReadOnlyList<BookDto>>(books);
    }
}
```

### Dedicated Stores (the justified exception)

When a slice needs concurrency control (`SELECT ... FOR UPDATE`, lease/outbox semantics,
`DbUpdateConcurrencyException` retry loops) or SQL that LINQ cannot express, write a
**hand-written, purpose-built store** with intention-named methods (e.g. `OutboxStore.ClaimBatchAsync`,
`LeaseStore.TryAcquireAsync`) — and document the justification in `decisions.md`.
Never a generic-per-entity abstraction.

**Rule of thumb:** everything → `ApplicationDbContext` in the slice. Concurrency or
specific SQL → dedicated hand-written store + `decisions.md` entry. Never reach for raw SQL
(`FromSqlRaw`) unless a query genuinely cannot be expressed in LINQ — and document that
in `decisions.md`.

---

## Feature Error Factory

Each feature defines its own error constants:

```csharp
// Features/BookFeature/BookErrors.cs
public static class BookErrors
{
    public static Error NotFound(Guid bookId) =>
        Error.NotFound("Book.NotFound", $"Book with ID '{bookId}' was not found.");

    public static Error DuplicateIsbn(string isbn) =>
        Error.Conflict("Book.DuplicateIsbn", $"A book with ISBN '{isbn}' already exists.");

    public static readonly Error InvalidPrice =
        Error.Failure("Book.InvalidPrice", "Book price must be greater than zero.");
}
```

Usage in handler:

```csharp
var existing = await db.Books.FindAsync([command.BookId], cancellationToken);
if (existing is null)
    return BookErrors.NotFound(command.BookId);  // implicit conversion to Result<T>
```

---

## Program.cs DI Registration Order

```csharp
// 1. OpenAPI
builder.Services.AddOpenApi();

// 2. Infrastructure (DbContext)
builder.Services.AddSQLDatabaseConfiguration(builder.Configuration);

// 3. Endpoint auto-discovery
builder.Services.RegisterApiEndpointsFromAssembly(Assembly.GetExecutingAssembly());

// 4. Health checks
builder.Services.AddHealthChecksConfiguration();

// 5. Validators (MUST be before handlers)
builder.Services.AddValidatorsFromAssembly(typeof(CreateBookValidator).Assembly);

// 6. Handlers + Decorators (Scrutor auto-discovery)
builder.Services.AddHandlersFromAssembly(typeof(Program).Assembly);

// 7. Exception handling
builder.Services.AddExceptionHandler<CustomExceptionHandler>()
    .AddProblemDetails();
```

---

## Anti-Patterns

| Do NOT | Do Instead |
|--------|------------|
| Use MediatR `IRequest`/`Send()` | Use `IHandler<TReq, TRes>` resolved directly from DI |
| Create a service/manager layer | Handler uses `ApplicationDbContext` directly |
| Add a generic repository (`IRepository<T>`/`IUnitOfWork`) | `DbSet<T>` is the repository; `DbContext` is the unit of work |
| Use `Guid.NewGuid()` | Use `Guid.CreateVersion7()` for time-sortable IDs |
| Make handler classes non-sealed | Always `sealed class` |
| Use mutable DTOs (class with setters) | Always `sealed record` with positional parameters |
| Omit `CancellationToken` from async methods | Pass `CancellationToken` through every async call |
| Put business logic in endpoints | Endpoints only map HTTP and call `Match()` |
| Share handlers across features | Each feature has its own handler, even if similar |
| Use constructor + field for DI | Use primary constructors: `class Handler(IDep dep)` |
| Group by technical concern (Controllers/, Services/) | Group by feature: `Features/BookFeature/CreateBook/` |
| Route data access through any indirection layer | Inject `DbContext` and write LINQ in the slice; dedicated store only with `decisions.md` justification |
| Use raw SQL (`FromSqlRaw` / `ExecuteQuery(string)`) by default | Use LINQ; raw SQL only with a `decisions.md` justification |

---

*MORPH-SPEC by Polymorphism Tech*
