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