This article proposes a clean approach to make API endpoint code more maintainable by avoiding repeated failure-handling code. We’ll explore different ways to control flow and map domain results to HTTP responses using the Result pattern.
In Web API development, beyond handling success scenarios, you must handle failures and return appropriate HTTP status codes. Let’s consider the following controller example:
[ApiController]
public class PaymentsController(IPaymentService paymentService): ControllerBase
{
[HttpPost("payments/new")]
public async Task<IActionResult> NewPayment(NewPaymentInput model)
{
CreatedPayment payment = paymentService.Create(model.Map<NewPaymentDto>());
return Ok(payment);
}
}
And the PaymentService.
public class PaymentService: IPaymentService
{
public CreatedPayment Create(NewPaymentDto input)
{
// New payment processing
}
}
Common Anti-Pattern: Exception-Based Error Handling
How should we handle the “unhappy path” inside PaymentService? Often, developers raise custom exceptions and catch them at the middleware level, wrapping them into appropriate API responses:
public CreatedPayment Create(NewPaymentDto input)
{
CreatedPayment paymentResult = new();
TransactionResult transactionResult = transactionService.Create(input.User, input.Amount);
if(!transactionResult.Success)
{
throw new PaymentException("Transaction creation failure");
}
return paymentResult;
}
This is not a good idea because:
- Performance Cost: Throwing exceptions is expensive. The runtime must create stack traces and unwind the method stack appropriately.
- Code Complexity: Calling code must wrap operations in try-catch blocks.
- Implicit Behavior: The method signature doesn’t indicate that exceptions might be thrown, requiring documentation.
Note: Exceptions should handle truly exceptional cases — scenarios where you don’t know how to process the error, such as a lost connection to an external service.
Solution: The Result Pattern
We can improve this code by implementing the Result pattern using a simple wrapper:
public record ServiceResult<TData>(TData Data, string? Error)
{
public bool Success => string.IsNullOrEmpty(Error);
}
With this approach, the service code becomes
public ServiceResult<CreatedPayment?> Create(NewPaymentDto input)
{
CreatedPayment paymentResult = new();
TransactionResult transactionResult = transactionService.Create(input.User, input.Amount);
if(!transactionResult.Success)
{
return new ServiceResult(null, "Transaction creation failure");
}
return new ServiceResult(paymentResult, null);
}
Looks better. We can return the same model in the controller method now:
[HttpPost("payments/new")]
public IActionResult NewPayment(NewPaymentInput model)
{
ServiceResult<CreatedPayment?> result = paymentService.Create(model.Map<NewPaymentDto>());
return Ok(result);
}
However, this approach has drawbacks:
- Data Exposure: Internal DTO ServiceResult is exposed to external clients
- Code Pollution: Handling error results with different HTTP status codes requires many if-then blocks in every method. What if we need to return different HTTP status codes in a response depending on the error case? Let’s say in the case the user doesn’t have enough money in an account.
Enhanced Result Pattern with Error Codes
To handle different HTTP status codes based on error types, let’s enhance our approach:
public record ServiceResult<TData>(TData Data, Error? Error)
{
public bool Success => Error != null;
}
public record Error(string Message, int Code);
public ServiceResult<CreatedPayment?> Create(NewPaymentDto input)
{
CreatedPayment paymentResult = new();
decimal funds = accountService.GetFunds(input.User);
if(funds < input.Amount)
{
return new ServiceResult(null, new Error("Insufficient funds amount. Please deposit additional funds.", ErrorCode.Account));
}
TransactionResult transactionResult = transactionService.Create(input.User, input.Amount);
if(!transactionResult.Success)
{
return new ServiceResult(null, new Error("Transaction creation failure", ErrorCode.ExternalService));
}
return new ServiceResult(paymentResult, null);
}
[HttpPost("payments/new")]
public IActionResult NewPayment(NewPaymentInput model)
{
ServiceResult<CreatedPayment?> result = paymentService.Create(model.Map<NewPaymentDto>());
if(!result.Success)
{
if(result.Error.Code == ErrorCode.Account)
return BadRequest(result.Error);
if(result.Error.Code == ErrorCode.ExternalService)
return Problem(result.Error, statusCode: 500);
}
return Ok(payment);
}
Messy? Let’s get rid of this. We have different ways:
- We can raise custom exceptions still.
- Extract base class with method that handles ServiceResult and returns appropriate response.
- Use middleware
Solution 1: Base Controller Approach
Exceptions — expensive and not implicit as I pointed out above. Let’s try to write a handler method and extract it into a base class:
public class BaseApiController: ControllerBase
{
public IActionResult HandleServiceResult<T>(ServiceResult<T> result)
{
if (!result.Success)
{
return new ObjectResult(new ApiError { Message = result.Error.Message });)
{
StatusCode = result.Error?.Code switch
{
ErrorCode.Account=> StatusCodes.Status400BadRequest,
ErrorCode.Unauthorized=> StatusCodes.Status401Unauthorized,
ErrorCode.NotFound => StatusCodes.Status404NotFound,
ErrorCode.ExternalService=> StatusCodes.Status500InternalServerError,
_ => StatusCodes.Status500InternalServerError
}
};
}
return new ObjectResult(result.Data)
{
StatusCode = StatusCodes.Status200OK
};
}
}
So finally we can use it:
[ApiController]
public class PaymentsController(IPaymentService paymentService): BaseApiController
{
[HttpPost("payments/new")]
public IActionResult NewPayment(NewPaymentInput model)
{
ServiceResult<CreatedPayment?> result = paymentService.Create(model.Map<NewPaymentDto>());
return HandleServiceResult(payment);
}
}
Better. Isn't it? But this solution has cons — a base class with a handler method makes dependence on the single base class and forces us to wrap every return result into a call of the handler.
Solution 2: Action Filter Approach
Let’s consider middlewares. As we remember, the ASP.NET request pipeline consists of a sequence of request delegates, called one after the other. A middleware is called twice: for an incoming request and response. We would rather not process every request and every response—overhead. Here is where action filters are coming in. They are part of the common request pipeline and allow you to run custom code before or after specific steps in the request pipeline. There are many types of action filters. We need IResultFilter. This interface contains 2 methods:
- OnResultExecuting — called before the action result executes
- OnResultExecuted — called after the action result executes
We have to send a processed response to API client, so we have to implement OnResultExecuting:
public class ServiceResultFilterAttribute: Attribute, IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
if (context.Result is ObjectResult result)
{
if (result.Value is IServiceResult serviceResult)
{
if (!serviceResult.Success)
{
context.Result = new ObjectResult(serviceResult.Value)
{
StatusCode = result.Error?.Code switch
{
ErrorCode.Account=> StatusCodes.Status400BadRequest,
ErrorCode.Unauthorized=> StatusCodes.Status401Unauthorized,
ErrorCode.NotFound => StatusCodes.Status404NotFound,
ErrorCode.ExternalService=> StatusCodes.Status500InternalServerError,
_ => StatusCodes.Status500InternalServerError
}
}
};
}
else
{
context.Result = new ObjectResult(serviceResult.Value)
{
StatusCode = StatusCodes.Status200OK
};
}
}
}
public void OnResultExecuted(ResultExecutedContext context)
{
}
}
I made it an attribute to apply to a controller or controller’s method. So we can make the payment method cleaner:
[ApiController]
[ServiceResultFilter]
public class PaymentsController(IPaymentService paymentService): BaseApiController
{
[HttpPost("payments/new")]
public ServiceResult<CreatedPayment?> NewPayment(NewPaymentInput model)
{
return paymentService.Create(model.Map<NewPaymentDto>());
}
}
Also, we can apply the filter globally:
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add<ServiceResultFilter>();
});
I added the marker interface IServiceResult to distinguish when controller methods return ServiceResult objects instead of IActionResult
public record ServiceResult<TData>(TData Data, Error? Error): IServiceResult
{
public bool Success => Error != null;
}
So what about Minimal API, is it possible to apply this approach? — Yes.
public class ServiceResultFilter: IEndpointFilter
{
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
// Invoke the handler
object? result = await next(context);
// Handle endpoint result
if (result is IServiceResult serviceResult)
{
if (!serviceResult.Success)
{
return result.Error?.Code switch
{
ErrorCode.Account => Results.BadRequest(result.Error.Message),
ErrorCode.Unauthorized => Results.Unauthorized(),
ErrorCode.NotFound => Results.NotFound(result.Error.Message),
ErrorCode.ExternalService => Results.InternalServerError(result.Error.Message),
_ => Results.InternalServerError(result.Error.Message)
};
}
}
return Results.Ok(result.Data);
}
}
And applying to the endpoint
app.MapPost("/payments/new", async (
NewPaymentInput request,
IPaymentService paymentService) =>
{
return paymentService.Create(request.Map<NewPaymentDto>());
})
.AddEndpointFilter<ServiceResultFilter>();
So we successfully implemented Result pattern and made the controller’s code simpler by moving mapping logic to the Result filter making the code cleaner and testable.
Alternative Approaches
Other approaches worth considering include:
- Extension methods — as discussed by Milan Jovanović
- Custom middleware — for global handling
- OneOf library — for discriminated union types
Conclusion
In this article, we explored refactoring API endpoints by:
- Implementing the Result pattern to unify domain results and separate known error handling from truly exceptional cases
- Extracting mapping logic from controllers/endpoints to result filters, improving code readability and testability
- Providing clean, maintainable solutions that work with both traditional controllers and Minimal APIs
Top comments (0)