Handling Errors In Web Api Core
I guess the first place to start with this is what do I mean handing errors in a web api project? Well, it really means how do we return an error from a web api to the client? So this little post will cover my preferred way. While I accept it's not the most "efficient" the truth is, it does not need to be. Although I will cover another pattern I'm going fond off.
Exception Filtering
I consider there to be two main types of exceptions in a web api project. The system exception and the domain exception. The system exception being the exceptions thrown within the framework or application (Yes I am going to bundle them as one). The domain exception which are exception for our.... well domain.
For this approach we want an exception filter in the middleware to listen for exceptions. When an exception is thrown, we want to map the exception to a response. System exceptions should be rare and be considered 500 while our domain exceptions could be mapped to a responsef code. For example throwing a EntityNotFoundException would map to 404, EntityValidationException would map to 400.
Lets start off with a simple endpoint.
app.MapGet("/{id:long}", (long id) =>
{
if (id == 4)
{
throw new Exception("Invalid Id");
}
return Results.Ok(id);
});So ignoring the fact we would just return bad request here. This code will return the following response if we hit this endpoint with the id equal 4.
GET http://localhost:5113/4
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Date: Thu, 07 Aug 2025 19:02:59 GMT
Server: Kestrel
Transfer-Encoding: chunked
System.Exception: Invalid Id
at Program.<>c.<<Main>$>b__0_0(Int64 id)
at lambda_method1(Closure, Object, HttpContext)
at Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
HEADERS
=======
Accept: application/json
Host: localhost:5113
User-Agent: IntelliJ HTTP Client/JetBrains Rider 2024.3.7
Accept-Encoding: br, deflate, gzip, x-gzip
Response code: 500 (Internal Server Error); Time: 12488ms (12 s 488 ms); Content length: 498 bytes (498 B)
This is no good. First of all when an exception is thrown we want to return as little information about the system as we can (ignoring the fact this is debug mode and we get nothing to work with other than 500 status code). We also want to make sure our status codes are in order. Currently we're returning Response Code 500 for what is considered a validation error. 500 response codes usually means all hands on deck. In production you would want to keep an eye open for 500 response codes, they usually require support. However in this case we want to return something like error code 400 (bad request).
Filtering
As mentioned above in this example, we could just return BadRequest from the controller, but what if the exception is thrown in a service?
To start create a simple class to represent the error, this can be expanded with domain error codes or helper links to support pages.
public class ApiError
{
public string Message { get; set; }
}This might be better off as a record. But this can be expanded to include other details
Secondly we want to to create a custom exception to represent invalid ids
class InvalidIdException : Exception
{
public InvalidIdException(long id) : base($"Invalid id {id}")
{
}
}When creating domain exception, you can make full use of the constructor to provide more consistent messages.
Finally add the 'UseExceptionHandler' middleware. When an exception is thrown in the application, this middleware will pick up that exception and allow us to handle it.
var app = builder.Build();
app.UseHttpsRedirection();
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var exceptionHandlerPathFeature =
context.Features.Get<IExceptionHandlerPathFeature>();
if (exceptionHandlerPathFeature?.Error is InvalidIdException)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new ApiError()
{
Message = exceptionHandlerPathFeature.Error.Message,
});
}
});
});We can use pattern matching to match exception types to a response code. This example can be improved with a default message for 500 exceptions
Finally remove the exception in the controller and replace with our new one.
app.MapGet("/{id}", (long id) =>
{
if (id == 4)
{
throw new InvalidIdException(id);
}
return Results.Ok(id);
});Passing the id as a constructor provides more consistent messages to the client
And Test
GET http://localhost:5113/4
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Date: Thu, 07 Aug 2025 19:13:41 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Transfer-Encoding: chunked
{
"message": "Invalid id 4"
}
Response file saved.
> 2025-08-07T201341.400.json
Response code: 400 (Bad Request); Time: 2565ms (2 s 565 ms); Content length: 26 bytes (26 B)
The response is 400 rather 500 with the message passed in the exception return to the client. This can be shown in the UI or whatever
The Api Response
I been playing with a new pattern when it comes to handling errors in the backend. First we want to implement the exception filter anyway to handle unhandled exceptions. But rather than throwing domain exceptions and letting the excpetion filter handle it, we instead wrap our service responsed in a service result
public class ServiceResult<T>
{
public ServiceResult(T data)
{
Data = data;
IsSuccess = true;
}
public ServiceResult(string message, bool isSuccess)
{
Message = message;
IsSuccess = isSuccess;
}
public T? Data { get; set; }
public string? Message { get; set; }
public bool IsSuccess { get; set; }
}Create a new ServiceResult object, this will be responsible for wrapping the response from our services.
In our basic example, we can remove the exception and return the ServiceResult with the correct status code.
app.MapGet("/{id}", (long id) =>
{
if (id == 4)
{
Results.BadRequest(new ServiceResult("Invalid id 4", false));
}
return Results.Ok(new ServiceResult<long>(id));
});I'm not 100% on the above, it's just another object to wrap all your services in. But I do find it makes passing information to the client a lot nicer. It really comes down to design, performance, perferences.