Backend guides
Adding an API version
Introduce a new API version (v2) without breaking existing clients.
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.
WithNamemust be unique across the whole app, so prefix it with the version (V2_Users_GetMe). Thev1class keeps its ownUsers_GetMe. - Reuse the handler, change only the shape. The example above returns a leaner
Responsethanv1does, but it sends the sameGetMeQuery. New versions are where you reshape requests and responses; the logic inSlicekit.Corestays 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
v1response in place. Changing the JSON av1endpoint returns is a breaking change even though the route is identical. Reshape inv2instead. - Share handlers, fork endpoints. Edits to a
Slicekit.Corehandler reach every version. If a change would alter whatv1returns, 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. Shippingv2does not move the frontend automatically. Migrate it slice by slice infrontend/src/features/<slice>/api.tswhen eachv2endpoint 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 partialMapV2Endpointsmethod inRegisterEndpoints.cs. - Mounted the version group with
app.VersionGroup(ApiVersions.V2).MapV2Endpoints(). - Created
Endpoints/v2/with endpoint classes under theSlicekit.Api.Endpoints.V2namespace. - Gave every endpoint a version-prefixed
WithName(V2_...). - Reused
Slicekit.Corehandlers; reshaped only the request and response records. - Added new route groups in
Groups.csonly for genuinely new domains. - Left the
v1folder, scan filters, and response shapes untouched. - Migrated frontend calls in
frontend/src/features/<slice>/api.tsas eachv2endpoint lands.