Posted 12/22/2025
Minimal APIs make it tempting to write everything inline, but this quickly becomes unmaintainable. When endpoints handle validation, business logic, error handling and response formatting all in one place, they become difficult to test and reuse. The solution is to extract business logic into dedicated handlers, leaving endpoints responsible only for routing.
To keep Minimal API endpoints clean and maintainable, business logic should be implemented outside the endpoint itself, typically in dedicated handler classes. Endpoints should be responsible only for:
Basic handler example
public record WeatherForecastPayload(string Location, int Days);
public class WeatherForecastRequestHandler
{
public Task<string[]> HandleAsync(
WeatherForecastPayload request,
CancellationToken cancellationToken)
{
return Task.FromResult(new[]
{
"Freezing", "Bracing", "Chilly", "Cool",
"Mild", "Warm", "Balmy", "Hot",
"Sweltering", "Scorching"
});
}
}
Endpoint using the handler
app.MapPost("/create-forecast",
async (
[FromBody] WeatherForecastPayload payload,
WeatherForecastRequestHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(payload, cancellationToken);
if (result is not null)
{
return Results.Ok(result);
}
return Results.BadRequest();
});
This works, but as soon as response logic becomes more complex, endpoints start to grow and lose readability.
Most handlers:
We can formalize this with a common interface with unified response models:
public interface IHttpRequestHandler<TResponse, in TRequest>
{
Task<HttpDataResponse<TResponse>> HandleAsync(
TRequest request,
CancellationToken cancellationToken);
}
Unified response models
public class DataResponse<T>
{
public T? Data { get; init; }
public IEnumerable<string> Errors { get; init; } = [];
}
public class HttpDataResponse<T> : DataResponse<T>
{
[JsonIgnore]
public HttpStatusCode StatusCode { get; init; }
}
The HttpStatusCode is used internally and is not exposed in the API response body, that’s why HttpDataResponse<T> inherits from DataResponse<T>.
Cleaner endpoints with handler-driven responses
app.MapPost("/create-forecast",
async (
[FromBody] WeatherForecastPayload payload,
IHttpRequestHandler<string[], WeatherForecastPayload> handler,
CancellationToken cancellationToken) =>
{
var response = await handler.HandleAsync(payload, cancellationToken);
return Results.Json(
response,
statusCode: (int)response.StatusCode);
});
Now the endpoint is cleaner, but we can push this further with an extension method.
Simplifying with an extension
public static class HandlerExtensions
{
public static async Task<IResult> SendAsync<TResponse, TRequest>(
this IHttpRequestHandler<TResponse, TRequest> handler,
TRequest request,
CancellationToken cancellationToken)
{
var response = await handler.HandleAsync(request, cancellationToken);
return Results.Json(
response,
statusCode: (int)response.StatusCode);
}
}
With this extension method in place, endpoint logic is reduced to a single method call. The endpoint no longer handles response construction, it simply delegates execution.
app.MapPost("/create-forecast",
async (
[FromBody] WeatherForecastPayload payload,
IHttpRequestHandler<string[], WeatherForecastPayload> handler,
CancellationToken cancellationToken)
=> await handler.SendAsync(payload, cancellationToken));
This is our target pattern - endpoints become trivial one-liners. At this point, the endpoint contains no business or response logic. However, with this implementation IHttpRequestHandler<T> always expects both a request and a response type, so we need to handle the following cases:
public sealed record EmptyRequest;
SendAsync extension method to determine the appropriate HTTP status (e.g., NoContent, Ok).With HttpDataResponse<T>, handlers are HTTP-aware they return HttpStatusCode directly. This couples your handler layer to HTTP transport.
For many applications, this is pragmatic and sufficient. The handler layer is your HTTP boundary. Just ensure that:
IHttpRequestHandler implementations return HttpDataResponse<T>If this coupling concerns you, there’s an alternative approach.
If you need stricter separation (e.g., sharing handlers across gRPC, message queues, REST), you can use transport-agnostic status codes that get mapped at the endpoint level.
Unified response models
public enum HandlerStatusCode
{
Success = 0,
SuccessWithEmptyResult = 1,
ValidationError = 2,
InternalError = 4
}
public class HandlerResponse<T> : DataResponse<T>
{
[JsonIgnore]
public HandlerStatusCode StatusCode { get; init; }
}
Strict Handler interface
IHttpRequestHandler<T> was renamed to IStatusRequestHandler<T> because it now has its own status type instead of an HTTP status.
public interface IStatusRequestHandler<TResponse, in TRequest>
{
Task<HandlerResponse<TResponse>> HandleAsync(
TRequest request,
CancellationToken cancellationToken);
}
Mapping status codes in extension method
public static class HandlerExtensions
{
/// <summary>
/// Executes a request handler and maps the response to an appropriate HTTP result.
/// </summary>
public static async Task<IResult> SendAsync<TResponse, TRequest>(this IStatusRequestHandler<TResponse, TRequest> requestHandler, TRequest request, CancellationToken cancellationToken)
{
var response = await requestHandler.HandleAsync(request, cancellationToken);
return response.StatusCode switch
{
HandlerStatusCode.SuccessWithEmptyResult => Results.NoContent(),
HandlerStatusCode.Success => Results.Json(response, statusCode: (int)HttpStatusCode.OK),
HandlerStatusCode.ValidationError => Results.Json(response, statusCode: (int)HttpStatusCode.BadRequest),
HandlerStatusCode.InternalError => Results.Json(response, statusCode: (int)HttpStatusCode.InternalServerError),
_ => throw new InvalidOperationException($"Unknown HandlerStatusCode: {response.StatusCode}"),
};
}
}
When to use this approach:
The following example projects demonstrate both approaches:
This pattern transforms Minimal API endpoints from multi-line routing functions into single-line declarations that are consistent, testable and maintainable.
The core insight: endpoints should route, not implement. By standardizing on IHttpRequestHandler<TResponse, TRequest> and HttpDataResponse<T>, you gain:
The trade-off is handlers become HTTP-aware. For most applications, this is the right choice because handlers are your HTTP boundary. If you need transport-agnostic handlers (for gRPC, message queues, etc.), use the HandlerStatusCode approach shown above.