agentleFS
zdanovichnickzdanovichnick/dotnet-pilot/skills/vertical-slice/SKILL.md

vertical-slice

Skill5 starsChanged 6 days agoMITView on GitHub

Vertical Slice Architecture (VSA) for .NET APIs — feature-first folder structure, IEndpointGroup pattern, cross-cutting concerns, and testing approach.

11 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

3 rules. 0 are common, 2 are distinctive, and 1 is very specific to this project.

  • 0

    Common

    Most of the 8 edited versions we read keep these.

  • 2

    Distinctive

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use records. Never share request/response types between features — duplication here is intentional and correct.

    line 101
  2. Distinctive:

    Use FluentValidation per-request.

    line 114
  3. Very specific:

    Test each slice independently.

    line 177

“Common” is measured: kept by at least 6 in 10 of the 8 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Vertical Slice Architecture

Reference material for structuring .NET APIs by feature rather than by layer.

Core Concept

Organize code by feature (vertical slice), not by technical layer (horizontal). Each slice owns everything needed for one feature: endpoint, handler, DTOs, validation. Two features never share request/response types — coupling through shared DTOs is how VSA projects collapse back into layered code.

Use when most operations read or write a single entity with little business logic (CRUD-heavy APIs), or Clean Architecture feels like over-engineering.

Avoid when invariants span several entities, business rules outgrow CRUD, or event sourcing is on the roadmap — see "When to Use DDD" in skills/ddd/SKILL.md. Aggregate count alone doesn't decide it.

Folder Structure

src/MyApp/
  Features/
    Orders/
      CreateOrder/
        CreateOrderEndpoint.cs     # IEndpointGroup implementation
        CreateOrderHandler.cs      # Command handler (or inline in endpoint)
        CreateOrderRequest.cs      # Input DTO record
        CreateOrderResponse.cs     # Output DTO record
        CreateOrderValidator.cs    # FluentValidation validator
      GetOrder/
        GetOrderEndpoint.cs
        GetOrderQuery.cs
        GetOrderResponse.cs
      CancelOrder/
        CancelOrderEndpoint.cs
        CancelOrderCommand.cs
  Domain/                          # Shared only if 2+ features use it
    Orders/
      Order.cs
      OrderStatus.cs
  Infrastructure/
    Persistence/
      AppDbContext.cs
      Configurations/
  Program.cs

The Domain/ folder is for shared domain concepts only. If only one feature references an entity, keep it inside that feature's folder.

IEndpointGroup Pattern

Each feature registers its own route via IEndpointGroup. The interface is registered and scanned in Program.cs.

public interface IEndpointGroup
{
    void MapEndpoints(IEndpointRouteBuilder app);
}
// Features/Orders/CreateOrder/CreateOrderEndpoint.cs
public class CreateOrderEndpoint : IEndpointGroup
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", HandleAsync)
            .WithName("CreateOrder")
            .AddEndpointFilter<ValidationFilter<CreateOrderRequest>>()
            .RequireAuthorization();
    }

    private static async Task<IResult> HandleAsync(
        CreateOrderRequest request,
        AppDbContext db,
        CancellationToken ct)
    {
        var order = new Order(request.CustomerId, request.Items);
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
        return TypedResults.Created($"/orders/{order.Id}", new CreateOrderResponse(order.Id));
    }
}
// Program.cs — scan and register all IEndpointGroup implementations
var endpointGroups = typeof(Program).Assembly
    .GetTypes()
    .Where(t => t.IsAssignableTo(typeof(IEndpointGroup)) && !t.IsAbstract)
    .Select(Activator.CreateInstance)
    .Cast<IEndpointGroup>();

foreach (var group in endpointGroups)
    group.MapEndpoints(app);

DTOs

Use records. Never share request/response types between features — duplication here is intentional and correct.

// CreateOrderRequest.cs
public record CreateOrderRequest(Guid CustomerId, IReadOnlyList<OrderItemRequest> Items);
public record OrderItemRequest(Guid ProductId, int Quantity);

// CreateOrderResponse.cs
public record CreateOrderResponse(Guid OrderId);

Validation

Use FluentValidation per-request. Wire via a generic endpoint filter so individual endpoints don't repeat the call.

// Features/Orders/CreateOrder/CreateOrderValidator.cs
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty();
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.Quantity).GreaterThan(0);
        });
    }
}
// Infrastructure/Filters/ValidationFilter.cs
public class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var request = ctx.GetArgument<T>(0);
        var result = await validator.ValidateAsync(request);
        if (!result.IsValid)
            return TypedResults.ValidationProblem(result.ToDictionary());
        return await next(ctx);
    }
}

Register validators and the filter in DI:

builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped(typeof(ValidationFilter<>));

Cross-Cutting Concerns

Handle via endpoint filters, not base classes:

ConcernApproach
ValidationValidationFilter<TRequest>
Authentication.RequireAuthorization() or policy
Logging / timingLoggingFilter via AddEndpointFilter
Rate limitingRateLimiterOptions + .RequireRateLimiting()

Extracting to Shared Domain

Extract to Domain/ only when two or more feature slices reference the same concept and it carries meaningful behavior (not just a DTO shape):

  • Order entity referenced by CreateOrder, CancelOrder, GetOrder → extract to Domain/Orders/
  • OrderStatus enum used across multiple features → extract
  • CreateOrderRequest used by only one feature → keep it in the feature folder

The Domain/ folder must stay infrastructure-free: no EF Core attributes, no HTTP types, no Polly.

Testing

Test each slice independently. Use WebApplicationFactory for full-stack slice tests.

public class CreateOrderTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task CreateOrder_ValidRequest_ReturnsCreated()
    {
        var client = factory.CreateClient();
        var request = new CreateOrderRequest(Guid.NewGuid(), [new(Guid.NewGuid(), 2)]);

        var response = await client.PostAsJsonAsync("/orders", request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
    }
}

For handler-only tests, instantiate the handler directly with a DbContext pointed at a Testcontainers database (see skills/testing-dotnet/SKILL.md). Don't mock DbContext or use the EF in-memory provider — it skips constraints, transactions and SQL translation, so tests pass against behaviour production doesn't have.

Do / Don't

DoDon't
Keep each feature folder self-containedShare Request/Response DTOs between features
Use endpoint filters for cross-cutting concernsCreate BaseHandler or BaseEndpoint base classes
Extract to Domain/ only when truly sharedPut infrastructure code (EF, HTTP) in Domain/
Name files <Action><Feature><Suffix> (e.g., CreateOrderHandler.cs)Mix suffix conventions (CreateOrderService.cs + GetOrderHandler.cs)
Test slices via WebApplicationFactoryMock DbContext in slice tests

See Also

  • knowledge/decisions/adr-001-vsa-default.md — why VSA is the default for new APIs
  • knowledge/decisions/adr-005-multi-architecture.md — when to choose Clean Architecture or DDD instead
  • knowledge/common-infrastructure.md — IEndpointGroup interface definition and bootstrap

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.