Skip to content
Slicekit

Concepts

Adding a vertical slice

A step-by-step recipe for adding a new feature, from the command in Slicekit.Core to the thin HTTP endpoint in Slicekit.Api.

View .md
On this page

The anatomy of a slice

A feature lives in two deliberately separate projects. The slice itself, the command, its handler, validation and results, sits in Slicekit.Core under Features/. The HTTP endpoint is a thin adapter in Slicekit.Api under Endpoints/v1/:

api/src/Slicekit.Core/
  Features/
    Projects/
      CreateProject/
        Command.cs        // the request, a plain record
        Handler.cs        // the business logic
        Validator.cs      // FluentValidation rules
        Result.cs         // what the handler returns
  Domain/
    Project.cs            // the aggregate

api/src/Slicekit.Api/
  Endpoints/v1/Projects/
    CreateProjectEndpoint.cs   // route, policies, status codes

The split is the point: Slicekit.Core has no dependency on the web host. Handlers are invoked over Wolverine’s message bus, so the API is just one host. A CLI tool, a background worker or a scheduled job can dispatch the exact same commands without touching any HTTP code.

1. Define the command

A command is a plain record in Command.cs:

namespace Slicekit.Core.Features.Projects.CreateProject;

public sealed record CreateProjectCommand(Guid UserId, string Name);

2. Write the handler

The handler lives next to the command in Handler.cs and is discovered by Wolverine, with no registration required. It returns a Result<T> so failures map onto the shared error taxonomy:

public sealed class CreateProjectCommandHandler(AppDbContext db)
{
    public async Task<Result<CreateProjectResult>> HandleAsync(
        CreateProjectCommand command,
        CancellationToken ct = default)
    {
        var project = Project.Create(command.UserId, command.Name);   // aggregate raises ProjectCreated
        db.Projects.Add(project);
        await db.SaveChangesAsync(ct);
        return new CreateProjectResult(project.Id);
    }
}

3. Raise events from the aggregate

The aggregate owns its invariants and records what happened by raising an event:

public class Project : AggregateRoot
{
    public static Project Create(Guid ownerId, string name)
    {
        var project = new Project { Id = Guid.CreateVersion7(), OwnerId = ownerId, Name = name };
        project.Raise(new ProjectCreated(project.Id, name));
        return project;
    }
}

Raised events are published after the change is saved, to any interested handlers and to the transactional outbox. See CQRS and events.

4. Map the endpoint

The endpoint, in Slicekit.Api, translates HTTP into the command and the result into a response. Authorization, validation, rate limiting and CSRF are declared as route policy:

internal sealed class CreateProjectEndpoint : IEndpoint
{
    public static void Map(IEndpointRouteBuilder routes) =>
        routes.Projects().MapPost("/", HandleAsync)
            .RequirePermission(Allow.ProjectCreate)
            .RequireRateLimiting(RateLimitPolicies.Default)
            .AddEndpointFilter<ValidationEndpointFilter<Request>>()
            .RequireCsrf();

    private static async Task<Results<Created<Response>, ProblemHttpResult>> HandleAsync(
        Request request, ClaimsPrincipal principal, IMessageBus bus, CancellationToken ct)
    {
        var result = await bus.InvokeAsync<Result<CreateProjectResult>>(
            new CreateProjectCommand(principal.TryGetUserId() ?? throw new UnauthorizedAccessException(), request.Name), ct);

        if (!result.IsSuccess) return result.Error.ToProblem();
        return TypedResults.Created($"/projects/{result.Value.Id}", new Response(result.Value.Id));
    }
}

5. Test the slice

Unit-test the handler and the aggregate with the fast suite; cover the endpoint with an integration test backed by Testcontainers. Architecture tests enforce that slices do not reach across feature boundaries.

dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo

Conventions to keep

  • One feature, one folder. Do not scatter a feature across shared layers.
  • Endpoints stay thin. Routing, policies and status codes only; logic belongs in the handler.
  • Raise, do not publish, from aggregates. Dispatchers publish; aggregates raise.
  • No repository interfaces. Use AppDbContext directly.