C# Minimal API: A Practical Way to Keep Endpoints Clean
Source: Dev.to
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.
A Practical Way to Keep Endpoints Clean
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:
- Accepting input
- Delegating work to a handler
- Returning an HTTP response
Basic handler example
public record WeatherForecastPayload(string Location, int Days);
public class WeatherForecastRequestHandler
{
public Task 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.
Introducing a Unified Handler Abstraction
Most handlers:
- Accept a request
- Return a response
We can formalize this with a common interface and unified response models.
Handler interface
public interface IHttpRequestHandler<TRequest, TResponse>
{
Task<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; }
}
HttpStatusCode is used internally and is not exposed in the API response body; that’s why HttpDataResponse inherits from DataResponse.
Cleaner endpoints with handler‑driven responses
app.MapPost("/create-forecast",
async (
[FromBody] WeatherForecastPayload payload,
IHttpRequestHandler<WeatherForecastPayload, HttpDataResponse<string[]>> handler,
CancellationToken cancellationToken) =>
{
var response = await handler.HandleAsync(payload, cancellationToken);
return Results.Json(
response,
statusCode: (int)response.StatusCode);
});
The endpoint is now 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<TRequest, TResponse>(
this IHttpRequestHandler<TRequest, HttpDataResponse<TResponse>> 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.
The Target Pattern: Trivial Endpoints
app.MapPost("/create-forecast",
async (
[FromBody] WeatherForecastPayload payload,
IHttpRequestHandler<WeatherForecastPayload, HttpDataResponse<string[]>> 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 always expects both a request and a response type, so we need to handle the following cases:
Requests without input/payload
For endpoints without a request body, a simple marker type can be used, e.g., an empty record:
public sealed record EmptyRequest;
Empty response
The same approach can be applied to responses, allowing the SendAsync extension method to determine the appropriate HTTP status (e.g., NoContent, Ok).
The Trade‑off: HTTP‑Aware Handlers
With HttpDataResponse, handlers are HTTP‑aware – they return HttpStatusCode directly. This couples your handler layer to the HTTP transport.
For many applications, this is pragmatic and sufficient. The handler layer is your HTTP boundary. Just ensure that:
- Only
IHttpRequestHandlerimplementations returnHttpDataResponse - Lower layers (domain services, business logic) remain transport‑agnostic
If this coupling concerns you, there’s an alternative approach.
Alternative: Transport‑Agnostic Handlers
If you need stricter separation (e.g., sharing handlers across gRPC, message queues, REST), you can use transport‑agnostic status codes that get mapped to HTTP status codes at the API layer. This keeps the core handler logic completely independent of any transport concerns.
(Further implementation details omitted for brevity.)
Unified response models (transport‑agnostic)
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 was renamed to IStatusRequestHandler because it now has its own status type instead of an HTTP status.
public interface IStatusRequestHandler<TRequest, TResponse>
{
Task<HandlerResponse<TResponse>> HandleAsync(
TRequest request,
CancellationToken cancellationToken);
}
Mapping status codes in an 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<TRequest, TResponse>(
this IStatusRequestHandler<TRequest, TResponse> 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
- You’re building multiple transport layers (REST, gRPC, message queues)
- You have strict architectural boundaries between layers
- You want handlers completely decoupled from HTTP
Solution examples
The following example projects demonstrate both approaches:
- Using HTTP‑aware handlers – HTTP response codes are handled directly in the handler layer.
- Using transport‑agnostic handlers – the response model is independent of HTTP.
Conclusion
This pattern transforms Minimal API endpoints from multi‑line routing functions into single‑line declarations that are consistent, testable, and maintainable.
Core insight: Endpoints should route, not implement. By standardizing on IHttpRequestHandler/IStatusRequestHandler and their respective response models, you gain:
- Trivial endpoints that fit on one line
- Business logic isolated in testable handlers
- Consistent error handling and response formatting across your entire API
Trade‑off: Handlers become HTTP‑aware. For most applications this is the right choice because handlers sit at the HTTP boundary. If you need transport‑agnostic handlers (for gRPC, message queues, etc.), use the HandlerStatusCode approach shown above.