Skip to content
Slicekit

Backend guides

Adding an API version

Introduce a new API version (v2) without breaking existing clients.

View .md
On this page

How versioning is wired

Every route in Slicekit lives under /api/{version}. The version segment is not a string scattered across endpoints: it comes from a single registry, and each version owns a folder of endpoint classes that are discovered at compile time. Adding v2 means registering one more version, pointing the source generator at a new folder, and writing the endpoints. The v1 surface keeps working untouched, because nothing about it changes.

The pieces involved, all in api/src/Slicekit.Api:

Configuration/Versioning.cs        // the list of known versions
Endpoints/RegisterEndpoints.cs     // scans folders, maps each version group
Endpoints/Groups.cs                // route group helpers: Users(), Auth(), ...
Endpoints/v1/                      // the existing version's endpoint classes

Endpoints themselves are thin HTTP adapters. The command, handler and validation behind them live in Slicekit.Core and are shared across versions, so a new version is a new shape over the same business logic. See Adding a vertical slice for that split, and Architecture for the wider picture.

1. Register the version

ApiVersions is the single source of truth. Add the new constant and put it in All, in api/src/Slicekit.Api/Configuration/Versioning.cs:

namespace Slicekit.Api.Configuration;

internal static class ApiVersions
{
    public const string V1 = "v1";
    public const string V2 = "v2";

    public static readonly string[] All = [V1, V2];
}

All drives OpenAPI document generation, so a Swagger document for v2 appears automatically. Endpoint registration is still explicit (step 2): listing the version is not the same as mapping its routes.

2. Scan and map the new folder

Endpoints are not registered by hand. ServiceScan.SourceGenerator finds every IEndpoint whose type name matches a filter and emits the Map... method at compile time. Add a [ScanForTypes] attribute plus a matching partial method to api/src/Slicekit.Api/Endpoints/RegisterEndpoints.cs, mirroring the v1 block:

[ScanForTypes(AssignableTo = typeof(IEndpoint), Handler = "Map",
    TypeNameFilter = "Slicekit.Api.Endpoints.V2.*")]
private static partial IEndpointRouteBuilder MapV2Endpoints(this IEndpointRouteBuilder routes);

Then mount it under a version group in MapApiEndpoints, next to the existing v1 wiring:

var v2 = app.VersionGroup(ApiVersions.V2);
v2.MapV2Endpoints();

VersionGroup is the private helper that prefixes /api/{version}, requires authorization, applies the default rate limiter and the TOTP-setup filter, and advertises a 401 response. Every version inherits that baseline, so a v2 route is secured the same way a v1 route is.

3. Create the endpoint classes

Add api/src/Slicekit.Api/Endpoints/v2/ and place one class per endpoint, grouped into subfolders by domain (v2/Me/, v2/Admin/, and so on) exactly as v1 is laid out. The namespace must start with Slicekit.Api.Endpoints.V2 so the scan filter from step 2 picks it up.

Each class implements IEndpoint, declares its route group with an extension from Groups.cs, and dispatches to a Slicekit.Core handler over Wolverine’s message bus:

using Slicekit.Core.Features.Users.GetMe;
using Slicekit.Core.Permissions;

namespace Slicekit.Api.Endpoints.V2.Me;

internal sealed class GetMeEndpoint : IEndpoint
{
    public static void Map(IEndpointRouteBuilder routes) =>
        routes.Users().MapGet("/me", HandleAsync)
            .WithName("V2_Users_GetMe")
            .WithSummary("Get current user")
            .WithTags("Users")
            .RequirePermission(Allow.UserGetMe)
            .AllowWithoutTotpSetup()
            .Produces<Response>()
            .ProducesProblem(403);

    private static async Task<Results<Ok<Response>, ProblemHttpResult>> HandleAsync(
        ClaimsPrincipal principal,
        IMessageBus bus,
        CancellationToken ct)
    {
        var userId = principal.TryGetUserId()
            ?? throw new UnauthorizedAccessException("User ID claim not found or invalid.");
        var result = await bus.InvokeAsync<Result<MeResult>>(
            new GetMeQuery(userId, principal.TryGetActingUserId()), ct);
        if (!result.IsSuccess) return result.Error.ToProblem();

        var r = result.Value;
        return TypedResults.Ok(new Response(r.Id, r.Email, r.DisplayName));
    }

    public sealed record Response(Guid Id, string Email, string? DisplayName);
}

Two conventions matter here:

  • Give the endpoint a version-scoped name. WithName must be unique across the whole app, so prefix it with the version (V2_Users_GetMe). The v1 class keeps its own Users_GetMe.
  • Reuse the handler, change only the shape. The example above returns a leaner Response than v1 does, but it sends the same GetMeQuery. New versions are where you reshape requests and responses; the logic in Slicekit.Core stays shared. If a version needs genuinely new behavior, add a new command and handler as a vertical slice rather than branching on the version inside a handler.

4. Add a route group if needed

The route group helpers live in api/src/Slicekit.Api/Endpoints/Groups.cs as extension methods on IEndpointRouteBuilder:

internal static class ApiGroups
{
    extension(IEndpointRouteBuilder routes)
    {
        public RouteGroupBuilder Auth() => routes.MapGroup("/auth");
        public RouteGroupBuilder Users() => routes.MapGroup("/users");
        public RouteGroupBuilder ApiKeys() => routes.MapGroup("/api-keys");
        public RouteGroupBuilder Admin() => routes.MapGroup("/admin");
    }
}

These are version-agnostic: routes.Users() resolves to /api/v1/users or /api/v2/users depending on the version group the endpoint is mapped under. Reuse the existing groups. Only add a new one when v2 introduces a domain that does not exist yet, for example:

public RouteGroupBuilder Payments() => routes.MapGroup("/payments");

Keeping v1 working

Nothing in steps 1 through 4 edits the v1 folder, the v1 scan attribute, or Users_GetMe. That is the point: v1 clients keep hitting /api/v1/... against unchanged endpoints while v2 is built alongside. A few rules keep it that way:

  • Never reshape a v1 response in place. Changing the JSON a v1 endpoint returns is a breaking change even though the route is identical. Reshape in v2 instead.
  • Share handlers, fork endpoints. Edits to a Slicekit.Core handler reach every version. If a change would alter what v1 returns, branch it into a new slice rather than mutating the shared handler.
  • The frontend pins its version. The SPA hard-codes the version in each apiFetch('/api/v1/...') call. Shipping v2 does not move the frontend automatically. Migrate it slice by slice in frontend/src/features/<slice>/api.ts when each v2 endpoint is ready. See the API client for how those calls are structured.

Checklist

  • Added the version constant and entry in Configuration/Versioning.cs (ApiVersions.All).
  • Added a [ScanForTypes] attribute and partial MapV2Endpoints method in RegisterEndpoints.cs.
  • Mounted the version group with app.VersionGroup(ApiVersions.V2).MapV2Endpoints().
  • Created Endpoints/v2/ with endpoint classes under the Slicekit.Api.Endpoints.V2 namespace.
  • Gave every endpoint a version-prefixed WithName (V2_...).
  • Reused Slicekit.Core handlers; reshaped only the request and response records.
  • Added new route groups in Groups.cs only for genuinely new domains.
  • Left the v1 folder, scan filters, and response shapes untouched.
  • Migrated frontend calls in frontend/src/features/<slice>/api.ts as each v2 endpoint lands.