Skip to content

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.

  • A working Granit module (see Create a module)
  • References to Granit.Validation and Granit.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 Request suffix
  • Top-level returns use the Response suffix
  • Never use the Dto suffix
  • 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);

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.

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 Details
return TypedResults.Problem(
detail: "SKU already exists.",
statusCode: StatusCodes.Status409Conflict);
// Wrong -- returns plain string, not Problem Details
return 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.

CodeWhen to use
200 OKRequest processed, result in body
201 CreatedResource created, include Location header
204 No ContentOperation succeeded, nothing to return
400 Bad RequestSyntactically invalid request (malformed JSON)
404 Not FoundResource not found (routes with {id} parameter)
409 ConflictConcurrency conflict or duplicate
422 Unprocessable EntityBusiness 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.

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();