When should I throw exception vs. return error ActionResult in ASP.NET Core Web API project?

3.9k Views Asked by At

I am making an ASP.NET Core Web API project with controllers. All API controllers derive from ControllerBase that has NotFound() method. I use this method from controllers whenever I can't find a resource that the client requested. However, sometimes I want to encapsulate the whole logic out of the controller action into a separate service. In this case, I can't use the NotFound() method directly.

I was wondering should this service throw a custom MyNotFound exception when a resource can't be found. Then in the global exception handler, I can handle this exception and return a 404 status code to the client.

Or should the service return IActionResult and then in the service method I can return new NotFoundObjectResult() (just like ControllerBase.NotFound()) instead of throwing the exception method?

Trade-offs

What makes my mind tickle is the decision I need to make and the tradeoff it brings. If I decide that the service will be throwing an exception that makes code cleaner because the service is not dependent on ASP.NET Core abstractions such as IActionResult and NotFoundObjectResult. However, throwing exceptions is an expensive operation and it takes more time for the server to process it than simple object returns.

On the other hand, if the service returns IActionResult from the operation, it makes things faster in case of an error but it couples the service with ASP.NET Core types.

Both approaches have pros and cons and I can't decide which one to use.

Approach with throwing an exception

Here is the example code with the approach where I throw exceptions:

[Route("api/users")]
[ApiController]
public class UsersController : ControllerBase
{
    // ... unimportant code 

    [HttpDelete("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> DeleteUser([Required] string id)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest();
        }

        await userManagementService.DeleteUser(id); // all logic inside this service method

        return NoContent();
    }
}

Where UserManagementService is:

public class UserManagementService : IUserManagementService
{
    // ... unimportant code
    public Task DeleteUser(string id)
    {
        var user = await _dbContext.Users.FindAsync(id);
        if (user == null)
        {
            throw new MyNotFoundException($"User with id: {id} not found");
        }

        // ... code that deletes the user and cleanups all resources associated 
        // with it (eg. things not just in the db but also user files on the content server)
    }
}

Approach with returning IActionResult

[Route("api/users")]
[ApiController]
public class UsersController : ControllerBase
{
    // ... unimportant code 

    [HttpDelete("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> DeleteUser([Required] string id)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest();
        }

        return await userManagementService.DeleteUser(id); // all logic inside this service method
    }
}

Where UserManagementService is:

public class UserManagementService : IUserManagementService
{
    // ... unimportant code
    public Task<IActionResult> DeleteUser(string id)
    {
        var user = await _dbContext.Users.FindAsync(id);
        if (user == null)
        {
            return new NotFoundObjectResult($"User with id: {id} not found");
        }

        // ... code that deletes the user and cleanups all resources associated 
        // with it (eg. things not just in the db but also user files on the content server)

        // ... success
        new NoContentResult();
    }
}

Should UserManagementService.DeleteUser() method return IActionResult

7

There are 7 best solutions below

0
Guru Stron On BEST ANSWER

When should I throw exception vs. return error ActionResult in ASP.NET Core Web API project?

Rule of thumb that I have established in recent years is that exceptions should be thrown only in truly exceptional situations (i.e. resulting in the 500 Internal Server Error, and in general servers should not return 500s). There are several reasons:

On the other hand, if service returns IActionResult from the operation, it makes things faster in case of an error but it couples service with ASP.NET Core types.

And you should not return IActionResult from services, there are multiple options for return types which are better, like:

The basic idea being that you get the result and map it into response (possibly using some kind of chained fluent calls like MapSuccess if you don't want multiple if-elses or if-returns). TBH I have not found ideal one for me which would easily cover all cases but still prefer this to throwing/catching exceptions which can quickly can become cumbersome with multiple catch blocks.

2
Vivek Nuna On

you can return IActionResult as you are already returning NotFoundObjectResult from other places in your project. It's cleaner and simple as well, So no need to throw/catch exceptions. With this approach, It is easier to unit test as well. It will be helpful when implementing a middleware around IActionResult .

Throwing exceptions has an advantage if you have logic to manage resources separate from the controller which will also fulfil the separation of concern. You can have a centralized place to maintain the exceptions in middleware or a custom exception filter. Throwing exceptions makes the app slow, this can be avoided if it's very very less.

Both approaches have pros and cons. so ultimately it depends on your requirement.

Edit:

This is the continuation of my answer, as OP asked about the general design principles, so extending my answer. Hope it is able to help.

  • Single Responsibility Principle (SRP): A class should only have one responsibility. In your case, the UserManagementService should only be responsible for managing users. If you throw an exception, the service is only responsible for handling the error, which is a separate responsibility.
  • Separation of Concerns: Throwing an exception keeps the service decoupled from the HTTP-specific abstractions and concerns. This means that the service can be used in different contexts, such as non-web scenarios.
  • Principle of Least Astonishment: Returning an IActionResult might be more intuitive for other developers working with your codebase, as it aligns with the conventions and patterns established in ASP.NET Core. However, if you provide clear documentation and guidelines on how the service works, throwing an exception can also be a reasonable approach.
  • Performance Considerations: Throwing an exception incurs a performance overhead compared to returning a simple object, as it involves stack unwinding and exception handling mechanisms. If performance is a crucial factor and the resource not found scenario is expected to occur frequently, returning an error result might be a better choice.
  • Consistency: Consider the existing conventions and patterns in your codebase. If other components or services in your project consistently use exceptions for error handling, it would be more consistent to follow the same approach throughout the codebase.
  • Code Reusability: If you plan to reuse the UserManagementService in other projects or scenarios where ASP.NET Core is not the underlying framework, throwing an exception might be a better choice. It keeps the service decoupled from the framework-specific abstractions, making it more reusable.
  • Error Handling Strategy: Establish a consistent error-handling strategy throughout your project. If the project predominantly handles errors through exceptions and has a global exception handler, throwing a custom exception would align with the existing strategy.

In summary, if you prioritize separation of concerns, code reusability, and adherence to SRP, throwing a custom exception like MyNotFoundException would be a suitable approach. On the other hand, if performance is a significant concern and aligning with ASP.NET Core conventions is more important, returning an IActionResult might be a better choice.

Ultimately, the best approach depends on your specific project and requirements. You should consider the trade-offs and choose the approach that best fits your project's needs and aligns with the project's existing design and conventions.

3
Dan K. On

Never in production. An API should always return a meaningful message as Vivek has suggested at it should be returning standard HTTP responses. In development, throw an exception if it provides meaningful feedback for the developer for debugging purposes.

1
Gary Archer On

There are multiple possible solutions, which depend on the developer's preferences. I thought I would post an opinionated answer on the end-to-end behavior I prefer. It is focused on good supportability in APIs, and enabling the following qualities.

  • Useful API logs, to enable effective queries
  • Useful client errors
  • Separation of concerns
  • Clean API code

4xx SERIES ERRORS

In the service class I would write code similar to this. In relation to your question, throwing an exception can lend itself better to logging of 4xx requests:

public class UserManagementService : IUserManagementService
{
    public Task<UserResult> DeleteUser(string id)
    {
        var user = await _dbContext.Users.FindAsync(id);
        if (user == null)
        {
            var extraContext = "Some server side context";
            throw ErrorFactory.ClientError(
                404,
                "user_not_found",
                "Invalid delete user operation",
                extraContext);
        }

        ...
    }
}

I would not use exceptions as general flow control. Instead, this is throwing an exception at the point where the API has decided to return a client error response. By doing so, you collect the most useful context at the point where the information is available. This context is then available to logs, and only simple code is required.

Deleting a user with an invalid ID is not expected to be a common occurrence, so performance will be good enough. The service class runs business logic and does not use types from the hosting technology. Here I am using an error code to represent distinct causes of problems.

The exception would be processed by logging and exception middleware. If you don't like the REST status code in the above error object you could instead map from error codes to statuses in middleware classes.

The client would then receive a response with status 404 and a payload I could control, such as the following. In some cases, error codes can inform clients of specific types of error, so that the client can take a compensating action.

{
  "code": "user_not_found",
  "message": "Invalid delete user operation"
}

Meanwhile, the API could write a log entry such as the following, then ship this data to a log aggregation system:

{
  "id": "7af62b06-8c04-41b0-c428-de332436d52a",
  "utcTime": "2023-07-24T10:27:33.468Z",
  "apiName": "Users",
  "operationName": "deleteUser",
  "hostName": "somehost",
  "method": "DELETE",
  "path": "/users",
  "resourceId": "22",
  "clientApplicationName": "MobileApp",
  "userId": "a6b404b1-98af-41a2-8e7f-e4061dc0bf86",
  "statusCode": 404,
  "errorCode": "user_not_found",
  "correlationId": "15b030a2-c67d-01ae-7c3f-237b9a70dbba",
  "sessionId": "77136323-ec8c-dce2-147a-bc52f34cb7cd",
  "errorData": {
    "statusCode": 404,
    "context": "Some server side context",
    "clientError": {
      "code": "user_not_found",
      "message": "Invalid delete user operation"
    }
  }
}

You could query the log aggregation system to see how often this type of error was happening. Use of error codes enables the frequency of each type of error to be measured:

select * from apilogs where apiName='Users' and operationName='deleteUser' and error_code='user_not_found';

5xx SERIES ERRORS

For other types of error you will need to return a 500 error to the client:

public class UserManagementService : IUserManagementService
{
    public Task<IActionResult> DeleteUser(string id)
    {
        try
        {            
            var user = await _dbContext.Users.FindAsync(id);
            ...
        } 
        catch(SomeDbException ex)
        {
            var extraContext = "Some server side context";
            throw ErrorFactory.ServerError(
                "data_access_error", 
                "Problem encountered during data access",
                ex,
                errorContext);
        }
    }
}

The exception would again be processed by logging and exception middleware. The client would receive a response with status 500 and a different payload. This can include a unique ID for the error occurrence. The client might then render a screen with the ID and time details. These could be reported by end users to your technical support staff.

{
    "code": "data_access_error",
    "message": "Problem encountered during data access",
    "id": 88146,
    "utcTime": "2023-07-06T12:42:30.357Z"
}

The API would write a log entry such as this, which would again end up in a log aggregation system:

{
  "id": "7af62b06-8c04-41b0-c428-de332436d52a",
  "utcTime": "2023-07-06T12:42:30.403Z",
  "apiName": "Users",
  "operationName": "deleteUser",
  "hostName": "somehost",
  "method": "DELETE",
  "path": "/users",
  "resourceId": "22",
  "clientApplicationName": "MobileApp",
  "userId": "a6b404b1-98af-41a2-8e7f-e4061dc0bf86",
  "statusCode": 500,
  "errorCode": "data_access_error",
  "errorId": 88146,
  "correlationId": "15b030a2-c67d-01ae-7c3f-237b9a70dbba",
  "sessionId": "77136323-ec8c-dce2-147a-bc52f34cb7cd",
  "errorData": {
    "statusCode": 500,
    "context": "Some server side context",
    "clientError": {
      "code": "data_access_error",
      "message": "Problem encountered during data access",
      "id": 88146,
      "utcTime": "2023-07-06T12:42:30.357Z"
    },
    "serviceError": {
      "details": "Transaction (Process ID 58) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.",
      "stack": "at System.Data.SqlClient.ExecuteCommand ..."
    }
}

When such an error was reported, technical support staff would be able to look up the technical details associated to the error, using only the ID. With solid API logs, this would enable the problem to be understood and resolved quickly in many cases.

select * from apilogs where error_id=88146;

SUMMARY

Although there are many possible ways to deal with errors in APIs, I like to think behavior through in terms of end-to-end supportability. I have had a success with this type of logging when doing onsite support for financial systems. It also works well in larger development setups, when APIs are integrated across teams.

Once code is set up in this way, an organization is in a good position to deal with any intermittent (4xx or 5xx) errors that are causing problems for API clients. It requires some investment to get the above type of end-to-end flow working though. Out of interest, my example OAuth .NET API provides this type of behaviour, in case useful.

0
Simon Mourier On

What I usually do is for API controller calls is:

  • write .NET code natually, throw exceptions when I need too. The code that's not in controllers (but that controllers call) often has no knowledge of ASP.NET anyway. Note 3rd party code I use can throw exceptions too, so I need to somehow handle this.
  • write a middleware that handle these exceptions and return corresponding nice JSON ProblemDetails instances, like ASP.NET does.

Here is an example of such a custom middleware, it does quite the same as the default one:

using System;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace myStuff
{
    // this is abstract because we can't have multiple public 'Invoke' or 'InvokeAsync' methods on a middleware
    // some you'll have too derive it too add to the services
    public abstract class ExceptionHandlingMiddleware
    {
        private readonly RequestDelegate _next;

        protected ExceptionHandlingMiddleware(RequestDelegate next)
        {
            ArgumentNullException.ThrowIfNull(next);
            _next = next;
        }

        protected async Task DoInvoke(HttpContext context, ILogger? logger = null)
        {
            ArgumentNullException.ThrowIfNull(context);

            try
            {
                await _next(context);
            }
            catch (Exception ex)
            {
                logger?.LogError(ex);
                var handled = await HandleExceptionAsync(context, ex);
                if (!handled)
                    throw;
            }
        }

        protected virtual async Task<bool> HandleExceptionAsync(HttpContext context, Exception exception)
        {
            ArgumentNullException.ThrowIfNull(context);
            ArgumentNullException.ThrowIfNull(exception);

            if (context.Request.IsApiCall())
            {
                if (context.Response.HasStarted) // too late, no can do
                    return false;

                var details = await GetExceptionDetailsAsync(context, exception);
                if (details != null)
                {
                    context.Response.ContentType = "application/json";
                    context.Response.StatusCode = details.Status ?? StatusCodes.Status501NotImplemented;
                    await context.Response.WriteAsync(JsonSerializer.Serialize(details, JsonUtilities.SerializerOptions));
                    return true;
                }
            }
            return true;
        }

        protected virtual Task<ProblemDetails?> GetUnhandledExceptionDetailsAsync(HttpContext context, Exception exception)
        {
            ArgumentNullException.ThrowIfNull(context);
            ArgumentNullException.ThrowIfNull(exception);

            // the bad one (a real bug in our code or lower levels 3rd party code)
            return Task.FromResult<ProblemDetails?>(new ExceptionProblemDetails(HttpStatusCode.InternalServerError, exception, context.GetTraceId()));
        }

        // handle some exception with custom problem details
        protected virtual Task<ProblemDetails?> GetExceptionDetailsAsync(HttpContext context, Exception exception)
        {
            ArgumentNullException.ThrowIfNull(context);
            ArgumentNullException.ThrowIfNull(exception);

            if (exception is ArgumentException)
                return Task.FromResult<ProblemDetails?>(new ExceptionProblemDetails(HttpStatusCode.BadRequest, exception, context.GetTraceId()));

            if (exception is UnauthorizedAccessException)
                return Task.FromResult<ProblemDetails?>(new ExceptionProblemDetails(HttpStatusCode.Unauthorized, exception, context.GetTraceId()));

            if (exception is ForbiddenAccessException) // a new one
                return Task.FromResult<ProblemDetails?>(new ExceptionProblemDetails(HttpStatusCode.Forbidden, exception, context.GetTraceId()));

            // TODO: add some other special handling
            if (exception is SomeSpecialException)
                return Task.FromResult<ProblemDetails?>(new ExceptionProblemDetails(HttpStatusCode.UnprocessableEntity, exception, context.GetTraceId()));

            return GetUnhandledExceptionDetailsAsync(context, exception);
        }
    }

    // tools
    public static class HttpExtensions
    {
        // get trace id for correlation
        public static string? GetTraceId(this HttpContext? context)
        {
            if (context == null)
                return null;

            var feature = context.Features.Get<IHttpActivityFeature>();
            return feature?.Activity?.Id?.ToString() ?? context.TraceIdentifier;
        }

        // some utility class too determine if it's an API call
        // adapt to your context
        public static bool IsApiCall(this HttpRequest request)
        {
            if (request == null)
                return false;

            var isJson = request.GetTypedHeaders().Accept.Contains(new MediaTypeHeaderValue("application/json"));
            if (isJson)
                return true;

            if (request.Path.Value?.StartsWith("/api/") == true)
                return true;

            if (request.HttpContext != null)
            {
                var ep = request.HttpContext.GetEndpoint();
                if (ep != null)
                {
                    // check if class has the ApiController attribute
                    foreach (var metadata in ep.Metadata)
                    {
                        if (metadata is ApiControllerAttribute)
                            return true;
                    }
                }
            }
            return false;
        }
    }

    // our custom problem details
    public class ExceptionProblemDetails : ProblemDetails
    {
        public ExceptionProblemDetails(HttpStatusCode code, string? traceId = null)
        {
            Status = (int)code;
            Type = GetType(code);
            if (traceId != null)
            {
                Extensions["traceId"] = traceId;
            }
        }

        public ExceptionProblemDetails(HttpStatusCode code, Exception exception, string? traceId = null)
            : this(code, traceId)
        {
            ArgumentNullException.ThrowIfNull(exception);
            Title = exception.GetAllMessagesWithDots(); // concats messages
#if DEBUG
            Detail = exception.StackTrace;
#endif
        }

        public static string? GetType(HttpStatusCode code)
        {
            var webdav = false;
            if ((int)code >= 400)
            {
                string section;
                switch (code)
                {
                    case HttpStatusCode.BadRequest:
                        section = "6.5.1";
                        break;

                    case HttpStatusCode.PaymentRequired:
                        section = "6.5.2";
                        break;

                    case HttpStatusCode.Forbidden:
                        section = "6.5.3";
                        break;

                    case HttpStatusCode.NotFound:
                        section = "6.5.4";
                        break;

                    case HttpStatusCode.MethodNotAllowed:
                        section = "6.5.5";
                        break;

                    case HttpStatusCode.NotAcceptable:
                        section = "6.5.6";
                        break;

                    case HttpStatusCode.RequestTimeout:
                        section = "6.5.7";
                        break;

                    case HttpStatusCode.Conflict:
                        section = "6.5.8";
                        break;

                    case HttpStatusCode.Gone:
                        section = "6.5.9";
                        break;

                    case HttpStatusCode.LengthRequired:
                        section = "6.5.10";
                        break;

                    case HttpStatusCode.RequestEntityTooLarge:
                        section = "6.5.11";
                        break;

                    case HttpStatusCode.RequestUriTooLong:
                        section = "6.5.12";
                        break;

                    case HttpStatusCode.UnsupportedMediaType:
                        section = "6.5.13";
                        break;

                    case HttpStatusCode.ExpectationFailed:
                        section = "6.5.14";
                        break;

                    case HttpStatusCode.UpgradeRequired:
                        section = "6.5.15";
                        break;

                    case HttpStatusCode.UnprocessableEntity:
                        webdav = true;
                        section = "11.2";
                        break;

                    case HttpStatusCode.Locked:
                        webdav = true;
                        section = "11.3";
                        break;

                    case HttpStatusCode.FailedDependency:
                        webdav = true;
                        section = "11.4";
                        break;

                    case HttpStatusCode.InsufficientStorage:
                        webdav = true;
                        section = "11.5";
                        break;

                    default:
                        section = "6.5";
                        break;
                }

                if (webdav)
                    return "https://tools.ietf.org/html/rfc4918.html#section-" + section;

                return "https://tools.ietf.org/html/rfc7231#section-" + section;
            }
            return null;
        }
    }
0
Dmitry Pavlov On

For ASP.NET Core API I would use Global Exception Handler approach with catching all exceptions and wrapping up them with some FailureResponse JSON object in base controller class if something fails. See below the example of how to:

  1. Base conroller class helpers
  2. Example of real controller action
  3. Example of throwing exception from DI service.
  4. Configure global exception handler
  5. How would response object would look like

1. Excample of Base conroller class helpers

[ApiController]
public abstract class BaseController<TController> : ControllerBase
{
    private readonly ILogger<TController> _logger;

    protected BaseController(ILogger<TController> logger) => _logger = logger;

    protected async Task<ActionResult<TResponse>> ToActionResult<TResponse>(Func<Task<TResponse>> operation)
        where TResponse : SucceededResponse, new()
    {
        try
        {
            var response = await operation();
            response.Operation = ControllerContext.ActionDescriptor.ActionName;
            response.Message ??= "Operation succeeded";

            _logger.LogInformation($"Operation '{response.Operation}' succeeded for {User.Identity?.Name ?? "N/A"}");

            return new ApiResponseResult<TResponse>(HttpStatusCode.OK, response);
        }
        catch (Exception exception)
        {
            // NOTE: for any error on controller level we wrap and re-throw providing access to the controller context in ErrorHandling extensions
            throw ErrorHandling.Wrap(ControllerContext, exception);
        }
    }

    private class ApiResponseResult<TResponse> : ObjectResult
    {
        public ApiResponseResult(
            [ActionResultStatusCode] HttpStatusCode statusCode,
            [ActionResultObjectValue] TResponse response) : base(response)
        {
            StatusCode = (int)statusCode;
        }
    }
}

2. Example of real controller action

[Route("api/v1/users")]
[Authorize]
public class UsersController : BaseController<UsersController>
{
    private readonly IUserService _service;

    public UsersController(IUserService service, ILogger<UsersController> logger) : base(logger) => _service = service;

    /// <summary> Get current user details </summary>
    [HttpGet("me")]
    [SwaggerResponse(HttpStatusCode.OK, typeof(UserResponse))]
    public async Task<ActionResult<UserResponse>> Me(CancellationToken token)
    {
        return await ToActionResult(async () => new UserResponse
        {
            Data = await _service.GetMe(token)
        });
    }
}

3. Example of throwing exception from DI service

public class UserService : IUserService
{
    private readonly ICurrentUser _me;

    public UserService(ICurrentUser me) => _me = me;

    public async Task<UserDto> GetMe(CancellationToken token = default)
    {
        if (_me == null) throw ErrorHandling.NotAuthenticated();
        if (!_me.CanAccess()) throw ErrorHandling.NotAuthenticated();
        return ToUserDto(_me);
    }
}

4. Configure global exception handler

/// <summary>
/// Error handling extensions.
/// See the <seealso href="https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/">documentation</seealso> about ASP.NET Core middleware.
/// </summary>
public static class ErrorHandling
{
    public static IMvcBuilder AddErrorHandling(this IMvcBuilder builder)
    {
        // NOTE: placeholder for error handling related DI setup (if any will be needed)

        return builder;
    }

    /// <summary>
    /// Configures global error handling for API.
    /// See how to <see href="https://docs.microsoft.com/aspnet/core/web-api/handle-errors">Handle errors in ASP.NET Core web APIs</see>
    /// See how to <see href="https://docs.microsoft.com/aspnet/core/fundamentals/error-handling">Handle errors in ASP.NET Core</see> in general
    /// </summary>
    public static void ConfigureErrorHandling(this IApplicationBuilder app, ILogger logger)
    {
        // NOTE: we could use custom middleware class but the standard `app.UseExceptionHandler()` is the first middleware in the pipeline, so it catches everything
        // - see the middleware order https://docs.microsoft.com/aspnet/core/fundamentals/middleware#middleware-order-1
        // - read about "Global Error Handling in ASP.NET Core Web API" options https://code-maze.com/global-error-handling-aspnetcore 

        app.UseExceptionHandler(appBuilder =>
        {
            appBuilder.Run(async context =>
            {
                var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;

                if (ShouldBeAborted(exception))
                {
                    logger.LogWarning("Aborting connection silently because the client cancelled the request...");
                    context.Abort();
                    return;
                }

                // NOTE: we neither `app.UseDeveloperExceptionPage();` nor `app.UseStatusCodePages();` because we want to guarantee our API consumers always get JSON response
                context.Response.ContentType = "application/json";

                FailedResponse response;
                HttpStatusCode statusCode;
                if (exception is ControllerException controllerException)
                {
                    var operation = controllerException.Context.ActionDescriptor.ActionName;
                    var businessError = controllerException.InnerException;

                    response = new FailedResponse 
                    {
                        Message = businessError?.Message,
                        Details = businessError?.ToString(),
                        Operation = operation
                    };
                    statusCode = ToStatusCode(businessError);

                    logger.LogError(businessError, @"{ErrorMessage} ({StatusCode}): {Operation}",
                        controllerException.Message, statusCode, operation);
                }
                else
                {
                    response = new FailedResponse
                    {
                        Error = error?.ToString() ?? "Unknown error",
                        Details = exception?.ToString(),
                        Operation = "Unknown"
                    };
                    statusCode = ToStatusCode(exception);

                    logger.LogError(exception, @"API error {StatusCode}", statusCode);
                }

                context.Response.StatusCode = (int)statusCode;
                await context.Response.WriteAsync(response.ToJson()!);
            });
        });
    }

    /// <summary>
    /// For any error on controller level we wrap and re-throw providing access to the controller context
    /// </summary>
    /// <param name="exception"></param>
    /// <param name="context"></param>
    /// <returns></returns>
    public static Exception Wrap(this Exception exception, ControllerContext context) => new ControllerException(context, exception);

    
    public static AuthenticationException Unauthorized(string? message = null, Exception? inner = null) =>
        new(message ?? "Not authenticated", inner);

    public static UnauthorizedAccessException Forbidden(string? message = null, Exception? inner = null) =>
        new(message ?? "Not allowed", inner);

    public static InvalidOperationException InvalidOperation(string? message = null, Exception? inner = null) =>
        new(message ?? "Invalid operation", inner);

    public static InvalidOperationException Required(string what, string? of = null,  Exception? inner = null) =>
        new($"{of} {what} is required".TrimStart(), inner);

    public static KeyNotFoundException NotFound(string? what = null, string? key = null, Exception? inner = null) =>
        new($"Not found {what} {key}".TrimEnd(), inner);

    public static NotSupportedException NotSupported(string? what, string? value = null, Exception? inner = null) =>
        new($"Not supported {what} {value}".TrimEnd(), inner);

    private static HttpStatusCode ToStatusCode(Exception? exception)
    {
        return exception != null
            ? exception switch
            {
                AuthenticationException => HttpStatusCode.Unauthorized,
                UnauthorizedAccessException => HttpStatusCode.Forbidden,

                InvalidOperationException => HttpStatusCode.BadRequest,
                InvalidDataException => HttpStatusCode.BadRequest,
                ValidationException => HttpStatusCode.BadRequest,
                KeyNotFoundException => HttpStatusCode.BadRequest,
                NotSupportedException => HttpStatusCode.BadRequest,
                SelfCheckException => HttpStatusCode.BadRequest,
                FormatException => HttpStatusCode.BadRequest,

                BadHttpRequestException ex => (HttpStatusCode)ex.StatusCode,
                HttpRequestException ex => ex.StatusCode ?? HttpStatusCode.ServiceUnavailable,

                // NOTE: feel free to map specific exception type to appropriate HTTP status codes

                _ => HttpStatusCode.InternalServerError
            }
            : HttpStatusCode.InternalServerError;
    }

    private static bool ShouldBeAborted(Exception? exception)
    {
        return exception switch
        {
            null => false,
            _ => exception is OperationCanceledException || ShouldBeAborted(exception.InnerException)
        };
    }

    /// <summary>
    /// The custom exception to wrap application errors on controllers level
    /// </summary>
    private class ControllerException : Exception
    {
        public ControllerContext Context { get; }

        public ControllerException(ControllerContext context, Exception innerException) : base("API endpoint error", innerException) => Context = context;
    }
}

5. How would response object would look like

/// <summary> User details response </summary>
public record UserResponse : SucceededResponse<UserDto> {
}

public abstract record BaseResponse
{
    public string Operation { get; set; } = "Unknown";

    public string? Message { get; set; }


    /// <summary> Returns response as JSON text </summary>
    public override string ToString() => this.ToJson() ?? string.Empty;
}

public abstract record SucceededResponse<TData> : SucceededResponse where TData : new()
{
    public TData Data { get; init; } = new();
}

public record SucceededResponse : BaseResponse
{
}

public record FailedResponse : BaseResponse
{
    public string Error { get; set; } = string.Empty;
}
0
Steven On

I think only the controller should be using IActionResult.

Also, I would suggest that only a complex application should be moving application layer code out of controllers at all. Simple CRUD applications should just directly work in the controller.

If you put IActionResult into your service, you're preventing it from being used in other ways. Such as:

  • In server side blazor code
  • In a gRPC service method
  • In a SignalR hub
  • During startup
  • In a background service
  • In a health check
  • In a CLI tool

I think the way to think about this is that IActionResult should only be used at the boundary between your application code and the calling framework. If you are putting IActionResult into your service, you're coupling it to usage in your controller so why not just put the service code into the controller directly!

There ought to be a layer that converts incoming requests into the right calls to your application and domain code, and turns the results into valid responses. If you have a complex application, it's good to keep your application and domain code completely agnostic to this layer, as you might want it to change - right now it's a controller, maybe you later change it to something else like gRPC.

Another example is that I think using a "not found" error when trying to delete something could be too simplistic - in a complex application there are likely many reasons why a delete could fail, such as:

  • The item is in a protected state that prevents deletion, such as being locked by another user
  • The user does not have permission to delete the item
  • The user does not have permission to even read the item
  • The item does not exist

Having layers would allow you to more easily change your mind about these decisions too. You can write mapping code from your internal errors into IActionResult and continue to refactor it to keep it consistent.

Using exceptions in the application layer is fine, but you can also define your own result types too, or use something like MediatR or these functional C# extensions. Any choice is fine - there are certainly drawbacks to exceptions but you want to be sure to avoid over-engineering.