TL;DR
  • Avoid repetitive response handling in ASP.NET Core controllers by using helper methods or result wrapper patterns.
  • Helper methods reduce boilerplate for common responses like BadRequest, NotFound, and Ok.
  • The result wrapper pattern centralizes success and error handling, making controllers cleaner and responses consistent.
  • Keep business logic in services and HTTP response logic in controllers for better separation of concerns.
  • Use extension methods and middleware for standardized error handling and global exception management.
  • Consistent response patterns improve API documentation and client experience.
  • Minimal performance impact; benefits in maintainability and clarity far outweigh the overhead.

We’ve all been there. You’re building an API and you find yourself writing the same code over and over again:

if (!ModelState.IsValid)
    return BadRequest(ModelState);

var result = await _service.GetItemAsync(id);

if (result == null)
    return NotFound();

return Ok(result);

This pattern shows up in practically every controller method.

It works fine, but it’s making your codebase bloated with repetition.

Let’s look at some ways to clean this up.

Why We Keep Writing the Same Code

Controller methods pretty much always follow the same pattern: check if the input makes sense, do something with it, then return the right HTTP response.

This predictable flow leads to the same code popping up all over your controllers.

Take a look at these two controller methods:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
    if (id <= 0)
        return BadRequest("Invalid ID provided");

    var product = await _productService.GetByIdAsync(id);

    if (product == null)
        return NotFound($"Product with ID {id} not found");

    return Ok(product);
}

[HttpGet("category/{categoryId}")]
public async Task<IActionResult> GetProductsByCategory(int categoryId)
{
    if (categoryId <= 0)
        return BadRequest("Invalid category ID provided");

    var products = await _productService.GetByCategoryAsync(categoryId);

    if (products == null || !products.Any())
        return NotFound($"No products found in category {categoryId}");

    return Ok(products);
}

See how similar they are?

Different endpoints, same code structure. It’s a classic case of copy-paste programming.

Fix #1: Simple Helper Methods

The easiest approach is to create some helper methods that handle the common response patterns:

public class ApiController : ControllerBase
{
    protected IActionResult HandleResponse<T>(T result, int id)
    {
        if (result == null)
            return NotFound($"Item with ID {id} not found");

        return Ok(result);
    }

    protected IActionResult HandleResponse<T>(T result, string errorMessage = "No items found")
    {
        if (result == null)
            return NotFound(errorMessage);

        // Check if we got an empty collection
        if (result is IEnumerable<object> collection && !collection.Any())
            return NotFound(errorMessage);

        return Ok(result);
    }
}

With these helpers, our controller methods get a lot cleaner:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
    if (id <= 0)
        return BadRequest("Invalid ID provided");

    var product = await _productService.GetByIdAsync(id);
    return HandleResponse(product, id);
}

Fix #2: The Result Wrapper Pattern

If you want to get a bit fancier, you can create a result wrapper class that handles the different response types:

public class ServiceResult<T>
{
    public T Data { get; }
    public bool IsSuccess { get; }
    public string ErrorMessage { get; }
    public ServiceErrorType ErrorType { get; }

    // Success factory method
    public static ServiceResult<T> Success(T data) =>
        new ServiceResult<T>(data, true);

    // Error factory methods
    public static ServiceResult<T> NotFound(string message) =>
        new ServiceResult<T>(default, false, message, ServiceErrorType.NotFound);

    public static ServiceResult<T> Invalid(string message) =>
        new ServiceResult<T>(default, false, message, ServiceErrorType.BadRequest);

    // Private constructor so you have to use the factory methods
    private ServiceResult(T data, bool isSuccess, string errorMessage = null,
                         ServiceErrorType errorType = ServiceErrorType.None)
    {
        Data = data;
        IsSuccess = isSuccess;
        ErrorMessage = errorMessage;
        ErrorType = errorType;
    }
}

public enum ServiceErrorType
{
    None,
    NotFound,
    BadRequest
}

Add this handy extension method to convert the result to an ActionResult:

public static class ServiceResultExtensions
{
    public static ActionResult<T> ToActionResult<T>(this ServiceResult<T> result, ControllerBase controller)
    {
        if (result.IsSuccess)
            return controller.Ok(result.Data);

        return result.ErrorType switch
        {
            ServiceErrorType.NotFound => controller.NotFound(result.ErrorMessage),
            ServiceErrorType.BadRequest => controller.BadRequest(result.ErrorMessage),
            _ => controller.StatusCode(500, "An unexpected error occurred")
        };
    }
}

Now your services can return these result objects:

public async Task<ServiceResult<Product>> GetByIdAsync(int id)
{
    if (id <= 0)
        return ServiceResult<Product>.Invalid("Invalid ID provided");

    var product = await _repository.GetByIdAsync(id);

    if (product == null)
        return ServiceResult<Product>.NotFound($"Product with ID {id} not found");

    return ServiceResult<Product>.Success(product);
}

And your controller code becomes incredibly clean:

[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
    var result = await _productService.GetByIdAsync(id);
    return result.ToActionResult(this);
}

Which Approach Should You Choose?

ApproachProsCons
Standard Controller MethodsEasy to understand for newcomersLots of copy-paste code
Helper MethodsQuick to implement, less repetitionControllers still need some conditionals
Result WrapperSuper clean controllers, consistent responsesTakes more work to set up initially

Once you’ve standardized your API responses, you can verify their structure at runtime using a custom logging middleware, see Logging Request & Response Bodies in ASP.NET Core Middleware for details.

Key Takeaway

Writing the same response handling code over and over makes your API harder to maintain and more prone to inconsistencies.

The helper method approach works great for smaller projects, while the result wrapper really shines in larger apps where you need consistent behavior across many controllers.

Which approach you pick depends on your project size and team preferences, but either way, you’ll end up with cleaner code that’s easier to maintain.

Sometimes the simplest changes, like adding a few helper methods, can make your codebase significantly better.

For protecting your APIs from abuse and ensuring reliability, see Implementing Request Throttling Middleware in ASP.NET Core Using MemoryCache and Per-IP Limits.

Frequently Asked Questions

Why does standard ASP.NET Core response handling lead to repetitive code?

Standard response handling often results in repeated if/else blocks for null checks, validation, and status code returns. This duplication makes controllers harder to maintain and increases the risk of inconsistencies across endpoints.

What is a result wrapper pattern in ASP.NET Core APIs?

A result wrapper is a custom class that encapsulates both the data and status of an operation. Services return this wrapper with properties like Success, Data, and Error, allowing controllers to generate consistent HTTP responses with minimal code. Example:

public class ServiceResult<T> {
    public bool IsSuccess { get; }
    public T Data { get; }
    public string ErrorMessage { get; }
}

How does ActionResult simplify controller code in ASP.NET Core?

ActionResult allows returning either a specific type or an ActionResult, unifying method signatures. Combined with extension methods, it enables transforming service results into HTTP responses cleanly, reducing boilerplate.

Where should response handling logic reside: services or controllers?

Business logic and result creation should be in services, while controllers should translate those results into HTTP responses. This separation keeps domain logic reusable and controllers focused on HTTP concerns.

How can you handle validation errors consistently in ASP.NET Core APIs?

Use a standardized error response format with fields like error type, message, and validation details. Extension methods can convert ModelState errors into this format, ensuring all validation errors have a uniform structure and status code.

How do you centralize exception handling in ASP.NET Core APIs?

Implement middleware for global exception handling. Catch exceptions, map them to custom error types, and return consistent HTTP responses. This removes the need for try/catch blocks in controllers and ensures uniform error handling.

How does a consistent response pattern improve API documentation?

Consistent response patterns allow you to define common response schemas in Swagger/OpenAPI. This makes documentation more accurate, easier to maintain, and improves the developer experience for API consumers.

What is the performance impact of using result wrappers in ASP.NET Core?

The performance impact is negligible due to .NET’s optimized object allocation. The clarity and maintainability benefits far outweigh the minimal overhead, making result wrappers suitable for most APIs.

Can you provide an example of a helper method for API responses?

Yes. A helper method can reduce repetition by handling null checks and returning appropriate responses:

protected IActionResult HandleResponse<T>(T result, int id) {
    if (result == null)
        return NotFound($"Item with ID {id} not found");
    return Ok(result);
}

What are the pros and cons of helper methods vs result wrappers in ASP.NET Core?

Helper methods are quick to implement and reduce some repetition, but controllers still need conditionals. Result wrappers centralize success and error handling, making controllers cleaner and responses consistent, but require more setup initially.
See other aspnet-core posts