Add an endpoint
This guide walks through adding a complete Minimal API endpoint to a Granit application — from route definition to validation and error handling.
Prerequisites
Section titled “Prerequisites”- A working Granit module (see Create a module)
- References to
Granit.ValidationandGranit.ExceptionHandling
Step 1 — Define request and response DTOs
Section titled “Step 1 — Define request and response DTOs”DTOs follow strict naming conventions:
- Input bodies use the
Requestsuffix - Top-level returns use the
Responsesuffix - Never use the
Dtosuffix - Prefix with the module context to avoid OpenAPI schema conflicts
namespace Granit.Inventory.Endpoints;
public sealed record InventoryItemCreateRequest( string Name, string Sku, int Quantity, decimal UnitPrice);
public sealed record InventoryItemResponse( Guid Id, string Name, string Sku, int Quantity, decimal UnitPrice, DateTimeOffset CreatedAt);
public sealed record InventoryItemListResponse( IReadOnlyList<InventoryItemResponse> Items, int TotalCount);Step 2 — Add FluentValidation
Section titled “Step 2 — Add FluentValidation”Create a validator for the request DTO. Validators are discovered automatically
if the module uses Wolverine ([assembly: WolverineHandlerModule]). Otherwise,
register them manually.
using FluentValidation;
namespace Granit.Inventory.Endpoints;
public sealed class InventoryItemCreateRequestValidator : AbstractValidator<InventoryItemCreateRequest>{ public InventoryItemCreateRequestValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(200);
RuleFor(x => x.Sku) .NotEmpty() .MaximumLength(50);
RuleFor(x => x.Quantity) .GreaterThanOrEqualTo(0);
RuleFor(x => x.UnitPrice) .GreaterThan(0); }}Modules decorated with [assembly: WolverineHandlerModule] get automatic validator
discovery via AddGranitWolverine(). No manual registration needed.
Modules without Wolverine handlers must register validators explicitly:
public override void ConfigureServices(ServiceConfigurationContext context){ context.Services .AddGranitValidatorsFromAssemblyContaining<InventoryItemCreateRequestValidator>();}Without this registration, FluentValidationEndpointFilter<T> silently skips validation.
Step 3 — Create the endpoint class
Section titled “Step 3 — Create the endpoint class”Endpoints are static classes with handler methods. Use TypedResults for
strongly-typed return values.
using Microsoft.AspNetCore.Http.HttpResults;
namespace Granit.Inventory.Endpoints;
public static class InventoryItemEndpoints{ public static RouteGroupBuilder MapInventoryItemEndpoints( this RouteGroupBuilder group) { var items = group.MapGroup("/inventory-items") .WithTags("Inventory");
items.MapGet("/", GetAllAsync); items.MapGet("/{id:guid}", GetByIdAsync); items.MapPost("/", CreateAsync); items.MapPut("/{id:guid}", UpdateAsync); items.MapDelete("/{id:guid}", DeleteAsync);
return group; }
private static async Task<Ok<InventoryItemListResponse>> GetAllAsync( IInventoryService service, CancellationToken cancellationToken) { var result = await service.GetAllAsync(cancellationToken); return TypedResults.Ok(result); }
private static async Task<Results<Ok<InventoryItemResponse>, ProblemHttpResult>> GetByIdAsync( Guid id, IInventoryService service, CancellationToken cancellationToken) { var item = await service.GetByIdAsync(id, cancellationToken);
return item is null ? TypedResults.Problem( detail: $"Inventory item {id} not found.", statusCode: StatusCodes.Status404NotFound) : TypedResults.Ok(item); }
private static async Task<Created<InventoryItemResponse>> CreateAsync( InventoryItemCreateRequest request, IInventoryService service, CancellationToken cancellationToken) { var item = await service.CreateAsync(request, cancellationToken);
return TypedResults.Created( $"/api/v1/inventory-items/{item.Id}", item); }
private static async Task<Results<NoContent, ProblemHttpResult>> UpdateAsync( Guid id, InventoryItemUpdateRequest request, IInventoryService service, CancellationToken cancellationToken) { var success = await service.UpdateAsync( id, request, cancellationToken);
return success ? TypedResults.NoContent() : TypedResults.Problem( detail: $"Inventory item {id} not found.", statusCode: StatusCodes.Status404NotFound); }
private static async Task<Results<NoContent, ProblemHttpResult>> DeleteAsync( Guid id, IInventoryService service, CancellationToken cancellationToken) { var success = await service.DeleteAsync(id, cancellationToken);
return success ? TypedResults.NoContent() : TypedResults.Problem( detail: $"Inventory item {id} not found.", statusCode: StatusCodes.Status404NotFound); }}Step 4 — Register the endpoints in Program.cs
Section titled “Step 4 — Register the endpoints in Program.cs”var app = builder.Build();
await app.UseGranitAsync();
app.UseAuthentication();app.UseAuthorization();
var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build();
var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet);
api.MapInventoryItemEndpoints();
app.Run();Step 5 — Handle errors with Problem Details
Section titled “Step 5 — Handle errors with Problem Details”Granit uses RFC 7807 Problem Details for all error responses. Always use
TypedResults.Problem() instead of TypedResults.BadRequest<string>().
// Correct -- RFC 7807 Problem Detailsreturn TypedResults.Problem( detail: "SKU already exists.", statusCode: StatusCodes.Status409Conflict);
// Wrong -- returns plain string, not Problem Detailsreturn TypedResults.BadRequest("SKU already exists.");Granit.ExceptionHandling automatically converts unhandled exceptions into Problem
Details responses with appropriate status codes. The ProblemDetailsResponseOperationTransformer
declares these error responses in the OpenAPI documentation.
HTTP status code conventions
Section titled “HTTP status code conventions”| Code | When to use |
|---|---|
200 OK | Request processed, result in body |
201 Created | Resource created, include Location header |
204 No Content | Operation succeeded, nothing to return |
400 Bad Request | Syntactically invalid request (malformed JSON) |
404 Not Found | Resource not found (routes with {id} parameter) |
409 Conflict | Concurrency conflict or duplicate |
422 Unprocessable Entity | Business validation failed (FluentValidation) |
Step 6 — Add the validation endpoint filter
Section titled “Step 6 — Add the validation endpoint filter”To wire FluentValidation into the endpoint pipeline, add the validation filter to the route group:
var items = group.MapGroup("/inventory-items") .WithTags("Inventory") .AddEndpointFilter<FluentValidationEndpointFilter<InventoryItemCreateRequest>>();When validation fails, the filter returns a 422 Unprocessable Entity response
with Problem Details containing the validation errors.
Complete example
Section titled “Complete example”Here is the final Program.cs combining modules, endpoints, and validation:
using Asp.Versioning;using Granit.Core.Extensions;using MyApp.Host;
var builder = WebApplication.CreateBuilder(args);
await builder.AddGranitAsync<MyAppHostModule>();
var app = builder.Build();
await app.UseGranitAsync();
app.UseAuthentication();app.UseAuthorization();
var apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build();
var api = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet);
api.MapInventoryItemEndpoints();
app.MapHealthChecks("/healthz");
app.Run();Next steps
Section titled “Next steps”- Add API versioning — version your endpoints with URL segments
- Configure multi-tenancy — isolate data per tenant
- API & Web reference — full API module documentation