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
How endpoints are found and mapped
Section titled “How endpoints are found and mapped”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 |
public IServiceCollection AddEndpoints(Assembly assembly) { return services.Scan(scan => scan .FromAssemblies(assembly) .AddClasses(classes => classes.AssignableTo<IEndpoint>(), false) .AsImplementedInterfaces() .WithTransientLifetime()); } 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.
Add the endpoint
Section titled “Add the endpoint”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.
-
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.csThe IEndpoint: route, binding, resultRegisterCustomerRequest.csThe HTTP body, plus a ToCommand()extensionRegisterCustomerCommand.csThe message Wolverine routes to the handler RegisterCustomerCommandHandler.csThe use case RegisterCustomerResponse.cspublic sealed record RegisterCustomerResponse(Guid Id);RegisterCustomer.csThe domain factory, an extension(Customer)blockQueries are slices too.
GetCustomers/has aGetCustomerQuery, its handler, the endpoint, a request and a response. See Vertical slices. -
Define the request and response. Requests are
sealed classes withrequiredinit properties for what the caller must send. AToCommand()extension maps the request to the command, and takes route values as arguments where needed, for exampleUpdateCustomerRequest.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].GetCustomersRequestextendsPaginationRequest(thelimitandpageparameters) 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; }} -
Write the handler. A handler is a class whose name ends in
Handler, with aHandleAsyncmethod that takes the message first. Wolverine discovers it because the module’s assembly carries[assembly: WolverineModule]. DeclareCompanyIdandIPrincipalas 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 throwValidationExceptionon bad input. Writes go through the module’sDbContext, and reads through a Dapper repository. See CQRS. -
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 idwith"/customers/{id:guid}"Body [FromBody]on the request classQuery string [AsParameters]on the request classSending bus.InvokeAsync<TResponse>(message, cancellationToken)when the handler returns a value,bus.InvokeAsync(message, cancellationToken)when it doesn’tName .WithName(nameof(Method)), which becomes the OpenAPIoperationId -
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.Frontendnpm run generate:apinpm run checkgenerate:apibuilds the API in Release, which writessrc/AppointMe.Api/obj/openapi.json, then runs orval to rewritesrc/api/client.tsandsrc/api/client.schemas.ts.checktype-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.
What CompanyId and IPrincipal do
Section titled “What CompanyId and IPrincipal do”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:
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.
Errors: throw, don’t return
Section titled “Errors: throw, don’t return”Handlers signal failure by throwing a domain exception. Exception handlers in the API turn each type into a ProblemDetails response.
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.
OpenAPI metadata and authorization
Section titled “OpenAPI metadata and authorization”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:
.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.
Verify it works
Section titled “Verify it works”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.
Troubleshooting
Section titled “Troubleshooting”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.