Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 20269 min read

Add an endpoint

Add a .NET 10 Minimal API endpoint to an AppointMe module, with an auto-discovered IEndpoint class, a Wolverine handler that gets CompanyId and IPrincipal injected, and typed results for OpenAPI.

You'll build
One endpoint slice in an existing module
Time
About 15 minutes
Touches
One slice folder and the generated client
Before you start
A module that calls AddEndpoints

An endpoint is a class that implements IEndpoint. Each module registers its own with one assembly scan, and the API host maps them all under api/v1.

Piece Where Does
IEndpoint AppointMe.Shared/Endpoints/IEndpoint.cs One method, MapEndpoint(IEndpointRouteBuilder builder)
AddEndpoints(assembly) AppointMe.Shared/Endpoints/EndpointsServiceCollectionExtensions.cs A Scrutor scan that registers every class assignable to IEndpoint, internal ones included
The module’s Add…Module() For example CRM/AppointMe.Crm/Configuration/CrmModule.cs Calls .AddEndpoints(ModuleAssembly.Instance)
MapEndpoints() AppointMe.Shared/Endpoints/EndpointsApplicationBuilderExtensions.cs, called once in Program.cs Builds an api/v1 route group with an API version set and passes it to every endpoint
src/AppointMe.Shared/Endpoints/EndpointsServiceCollectionExtensions.csView source ↗
public IServiceCollection AddEndpoints(Assembly assembly)
{
return services.Scan(scan => scan
.FromAssemblies(assembly)
.AddClasses(classes => classes.AssignableTo<IEndpoint>(), false)
.AsImplementedInterfaces()
.WithTransientLifetime());
}
src/AppointMe.Shared/Endpoints/EndpointsApplicationBuilderExtensions.csView source ↗
var versionedGroup = app
.MapGroup("api/v1")
.WithApiVersionSet(apiVersionSet);
var endpoints = app.Services
.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (var endpoint in endpoints)
{
endpoint.MapEndpoint(versionedGroup);
}

So routes inside MapEndpoint are relative: "/customers" serves /api/v1/customers. Version 1 is the only version, and there’s no attribute or registration list to keep in sync.

One folder per use case, named verb-first. The endpoint stays thin: it binds HTTP, sends a message through Wolverine and returns a typed result.

  1. Create the slice folder. Put it in the feature folder it belongs to, named after the action. Customers/RegisterCustomer/ is the model:

    File Role
    RegisterCustomerEndpoint.cs The IEndpoint: route, binding, result
    RegisterCustomerRequest.cs The HTTP body, plus a ToCommand() extension
    RegisterCustomerCommand.cs The message Wolverine routes to the handler
    RegisterCustomerCommandHandler.cs The use case
    RegisterCustomerResponse.cs public sealed record RegisterCustomerResponse(Guid Id);
    RegisterCustomer.cs The domain factory, an extension(Customer) block

    Queries are slices too. GetCustomers/ has a GetCustomerQuery, its handler, the endpoint, a request and a response. See Vertical slices.

  2. Define the request and response. Requests are sealed classes with required init properties for what the caller must send. A ToCommand() extension maps the request to the command, and takes route values as arguments where needed, for example UpdateCustomerRequest.ToCommand(Guid id).

    src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomerRequest.csView source ↗
    public static class RegisterCustomerRequestExtensions
    {
    extension(RegisterCustomerRequest request)
    {
    public RegisterCustomerCommand ToCommand()
    {
    return new RegisterCustomerCommand
    {
    FirstName = request.FirstName,
    LastName = request.LastName,
    DateOfBirth = request.DateOfBirth,
    Gender = request.Gender,
    Email = request.Email
    };
    }
    }

    For query-string input, bind a class with [AsParameters]. GetCustomersRequest extends PaginationRequest (the limit and page parameters) and adds its own:

    src/CRM/AppointMe.Crm/Customers/GetCustomers/GetCustomersRequest.csView source ↗
    public sealed class GetCustomersRequest : PaginationRequest
    {
    [FromQuery(Name = "search")]
    public string? Search { get; init; }
    }
  3. Write the handler. A handler is a class whose name ends in Handler, with a HandleAsync method that takes the message first. Wolverine discovers it because the module’s assembly carries [assembly: WolverineModule]. Declare CompanyId and IPrincipal as parameters, inject everything else through the constructor, and check the permission before anything else:

    src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomerCommandHandler.csView source ↗
    public async Task<RegisterCustomerResponse> HandleAsync(RegisterCustomerCommand command, CompanyId companyId,
    IPrincipal principal, CancellationToken cancellationToken)
    {
    principal.Require(CustomerPermissions.Create);
    var customer = Customer.Register(
    companyId: companyId,
    name: PersonName.Create(command.FirstName, command.LastName),
    dateOfBirth: DateOfBirth.CreateOrNull(command.DateOfBirth, timeProvider),
    gender: Gender.ParseOrNull(command.Gender),
    email: Email.CreateOrNull(command.Email),
    registrationDate: timeProvider.GetUtcNow()
    );

    The command carries raw input, and the handler turns it into value objects through their factories (PersonName.Create, Email.CreateOrNull), which throw ValidationException on bad input. Writes go through the module’s DbContext, and reads through a Dapper repository. See CQRS.

  4. Write the endpoint. Map the route relative to the group, name it, and send the message with IMessageBus:

    src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomerEndpoint.csView source ↗
    internal sealed class RegisterCustomerEndpoint : IEndpoint
    {
    public void MapEndpoint(IEndpointRouteBuilder builder)
    {
    builder.MapPost("/customers", RegisterCustomer).WithName(nameof(RegisterCustomer));
    }
    private static async Task<Created<RegisterCustomerResponse>> RegisterCustomer(
    [FromBody] RegisterCustomerRequest request, IMessageBus bus, CancellationToken cancellationToken)
    {
    var result = await bus.InvokeAsync<RegisterCustomerResponse>(request.ToCommand(), cancellationToken);
    return TypedResults.Created($"/customers/{result.Id}", result);
    }
    }
    Part Convention
    Route values A typed parameter and a constraint: Guid id with "/customers/{id:guid}"
    Body [FromBody] on the request class
    Query string [AsParameters] on the request class
    Sending bus.InvokeAsync<TResponse>(message, cancellationToken) when the handler returns a value, bus.InvokeAsync(message, cancellationToken) when it doesn’t
    Name .WithName(nameof(Method)), which becomes the OpenAPI operationId
  5. Regenerate the client. Any change to a route, verb, request or response changes the contract, so regenerate in the same change:

    Terminal window
    cd src/AppointMe.Frontend
    npm run generate:api
    npm run check

    generate:api builds the API in Release, which writes src/AppointMe.Api/obj/openapi.json, then runs orval to rewrite src/api/client.ts and src/api/client.schemas.ts. check type-checks the call sites against the new client. See Typed API client.

Build your first feature walks through a command slice and a query slice end to end, from the migration to the React hook.

Declaring either parameter makes a Wolverine policy add middleware that loads it before your code runs, or fails the request with 403.

Parameter Loaded by Fails with
CompanyId companyId CompanyContextBehavior.Load, added by CompanyContextPolicy 403 Active company was not specified. when the request has no X-Company-Id
IPrincipal principal PrincipalContextBehavior.LoadAsync, added by PrincipalContextPolicy 403 User is not authorized to access requested company. when the user isn’t an employee of that company
Your principal.Require(...) call Your handler’s first line 403 The following permissions are required: <codes>.

You can see the middleware in the code Wolverine generates for a handler. This is the generated handler for the tutorial’s AddCustomerNoteCommandHandler:

src/AppointMe.Api/Internal/Generated/WolverineHandlers/AddCustomerNoteCommandHandler….cs (generated)
var principal = await AppointMe.Api.Wolverine.HandlerContext.PrincipalContextBehavior.LoadAsync(currentPrincipalResolver, cancellation).ConfigureAwait(false);
var companyId = AppointMe.Api.Wolverine.HandlerContext.CompanyContextBehavior.Load(_currentCompany, context);
var addCustomerNoteCommandHandler = new AppointMe.Crm.CustomerNotes.AddCustomerNote.AddCustomerNoteCommandHandler(crmDbContext, _timeProvider);
// …
var outgoing1 = await addCustomerNoteCommandHandler.HandleAsync(addCustomerNoteCommand, companyId, principal, cancellation).ConfigureAwait(false);

The same file wraps the call in an EF Core transaction enlisted in the Wolverine outbox, and calls SaveChangesAsync after it. To write these files, run ASPNETCORE_ENVIRONMENT=Codegen dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-launch-profile -- codegen write, the same codegen write the Dockerfile runs. The output folder, src/AppointMe.Api/Internal/Generated/, is git-ignored.

Why the company comes from a header: Multi-tenancy.

Handlers signal failure by throwing a domain exception. Exception handlers in the API turn each type into a ProblemDetails response.

src/AppointMe.Api/ErrorHandling/ErrorHandlingServiceCollectionExtensions.csView source ↗
return services
.AddProblemDetails()
.AddExceptionHandler<BadHttpRequestExceptionHandler>()
.AddExceptionHandler<ValidationExceptionHandler>()
.AddExceptionHandler<NotFoundExceptionHandler>()
.AddExceptionHandler<ConflictExceptionHandler>()
.AddExceptionHandler<AccessDeniedExceptionHandler>()
.AddExceptionHandler<ConcurrencyExceptionHandler>()
.AddExceptionHandler<GlobalExceptionHandler>();
Thrown Status ProblemDetails
BadHttpRequestException (binding failed) The exception’s status, usually 400 title: "Bad Request", detail: the message
ValidationException 400 title: "Validation Error", an error extension with the message
NotFoundException 404 title: "Resource Not Found", detail: the message
ConflictException 409 title: "Conflict", an error extension, and code when the exception has one
AccessDeniedException 403 title: "Access denied", detail: the message
DbUpdateConcurrencyException (a stale row version) 409 title: "Conflict", code: "concurrency_conflict"
Anything else 500 title: "Internal Server Error", detail: "Unexpected error occurred."

The common ones come from shared code: value-object factories throw ValidationException, LoadAsync query helpers throw NotFoundException, and principal.Require throws AccessDeniedException. The exception types live in AppointMe.Shared/Domain/Errors.

The OpenAPI document is generated from the endpoint’s signature, so the return type decides what the typed client knows.

You write The document says Example
Task<Created<T>> and TypedResults.Created(…) 201 with T’s schema RegisterCustomerEndpoint
Task<NoContent> and TypedResults.NoContent() 204 RescheduleAppointmentEndpoint
Task<T> 200 with T’s schema GetCustomersEndpoint
Task<IResult> 200 with no schema, even when the body returns Results.NoContent() UpdateCustomerEndpoint
Task 200 with no schema DeleteCustomerEndpoint
.WithName(nameof(GetCustomers)) operationId: GetCustomers, which orval turns into useGetCustomers and getGetCustomersQueryKey Every endpoint
.ExcludeFromDescription() Nothing; the endpoint is left out DemoLoginEndpoint

Each operation is tagged with its endpoint class name. Only the success response is documented, so the generated client types errors as unknown (for example GetCustomerQueryError). Prefer TypedResults and a typed return over IResult, so the client gets the real status and shape.

Authorization is on by default. AddAppointMeAuthorization() sets a fallback policy, so every endpoint without its own metadata requires an authenticated, registered user:

src/AppointMe.Api/Authorization/AuthorizationServiceCollectionExtensions.csView source ↗
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new RegisteredUserRequirement())
.Build());
Endpoint metadata Effect Used by
None The fallback policy: authenticated and registered Almost every endpoint
.AllowAnonymous() No authentication GetCurrentUserEndpoint, LoginEndpoint, SignupEndpoint
.RequireAuthorization() ASP.NET Core’s default policy, an authenticated user, instead of the fallback AcceptInvitationEndpoint, GetPendingInvitationsEndpoint, LogoutEndpoint

Permissions aren’t endpoint metadata. The handler checks them with principal.Require, so they apply however the message arrives. See Permissions.

Build, read the generated document, regenerate the client, and run the same Wolverine check CI runs.

Check Command Expect
It compiles dotnet build AppointMe.sln No errors
It’s in the contract Open src/AppointMe.Api/obj/openapi.json Your route under /api/v1/…, with your operationId
The client matches npm run generate:api, then git diff --stat src/AppointMe.Frontend/src/api/ The new hook in client.ts
The handler code generates dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-build --no-launch-profile -- codegen test Success!. Needs a Release build first; generate:api does one.

In the running app (Development only), https://localhost:7233/openapi/v1.json serves the same document.

Most problems are a route that was never mapped, a missing header, or a client that wasn’t regenerated.

Symptom Cause Fix
404 for the new route The class doesn’t implement IEndpoint, or its module doesn’t call AddEndpoints(ModuleAssembly.Instance) Implement IEndpoint; add the call to the module’s Add…Module()
The route is served at /api/v1/api/v1/… MapEndpoint repeats the prefix Map relative to the group: "/customers"
403 Active company was not specified. The request has no X-Company-Id header Send it. The SPA’s axios client adds it from the current company.
The new hook isn’t in client.ts The client wasn’t regenerated, or npx orval read an old obj/openapi.json Run npm run generate:api
The generated mutation has no response type The endpoint returns Task<IResult> Return Task<Created<T>>, Task<NoContent> or Task<T> with TypedResults
dotnet build fails in the GenerateOpenApiDocuments step The build runs the app’s startup without a database, and new startup code needs one Gate it on CodeGenerationDetection.IsRunningGeneration(), as Program.cs does for Hangfire
Why send every request through Wolverine instead of calling the handler?1 min · optional

The cross-cutting work lives in Wolverine middleware: the company, the principal, the EF transaction with the outbox, and publishing the aggregates’ domain events. bus.InvokeAsync gets all of it for one line in the endpoint, and the endpoint stays a thin HTTP adapter.

It also keeps the use case reachable from places that aren’t HTTP. A recurring job sends the same message for a given company with bus.InvokeForCompany, and the same middleware runs.