
Background
There was a time when my wife and I were both working for a U.S.-based company in Nepal. Since the platform we worked on was an e-commerce system for online food ordering, issues would often arise during our nighttime—customers facing errors while placing orders or other unexpected failures, which we’d be notified about via email.
To ensure smooth operations, I implemented global exception handling that sent automatic error alerts to the support team via email. This way, we’d immediately know when something went wrong and could take action before it affected too many customers. Given that order failures could lead to lost business, having real-time alerts was critical.
However, as a core developer, I often found myself directly involved in fixing these issues, sometimes in the middle of the night or on weekends.
I still remember one particular incident – It was midnight when I got the call. A critical API endpoint was down, and our biggest client couldn’t process orders. Scrolling through endless log files, I found the culprit – a cryptic error message with zero context: "Object reference not set to an instance of an object". No stack trace. No request ID. Nothing to help me understand what went wrong.
That night taught me a lesson I’ll never forget: proper error handling isn’t just nice to have – it’s essential.
I’ve spent the last decade building and maintaining .NET APIs, and I’ve seen firsthand how error handling can make or break a project. In this article, I’ll share what I’ve learned through trial and error, and many late-night debugging sessions.
The Evolution of Error Handling in .NET
When I first started working with .NET back in the Web Forms days, error handling was mostly an afterthought. We’d wrap some code in try-catch blocks, maybe redirect to an error page, and call it a day. Each developer had their own approach, leading to inconsistent implementations across projects.
Those were simpler times. Today’s distributed systems are far more complex, with microservices communicating across networks and countless potential points of failure.
ASP.NET Core brought a much-needed focus on standardized error handling. Microsoft embraced industry standards like RFC 7807 (Problem Details for HTTP APIs), and the framework now provides robust tools for handling exceptions and returning appropriate responses.
But having tools available doesn’t mean they’re being used effectively. I still encounter APIs that return nothing but a 500 status code when something goes wrong – leaving developers to guess what happened and how to fix it.
Why Good Error Handling Matters
Last year, I consulted for a company that was losing customers due to API reliability issues. Their logs were filled with exceptions, but the error responses provided to clients were so vague that even their own front-end team couldn’t figure out what was going wrong.
After implementing a comprehensive error-handling strategy, two things happened:
- Support tickets decreased significantly
- Average time-to-resolution for issues dropped by almost 70%
The reason was simple: developers could now understand what was happening and fix problems faster.
Good error handling isn’t just about catching exceptions – it’s about providing meaningful information that helps developers understand and address issues quickly. Here’s why it matters:
- Developer Experience: I’ve been on both sides of an API, and nothing frustrates me more than cryptic error messages. Clear error responses save hours of debugging time.
- Troubleshooting: When something goes wrong in production, you need to move fast. Standardized error formats with correlation IDs make tracking down issues much easier.
- Security: Proper error handling prevents sensitive information leaks while still providing actionable feedback.
- User Experience: Let’s face it – errors will happen. Good error handling allows you to translate technical issues into user-friendly messages.
- Reliability: A consistent approach to error handling makes your API more predictable and robust.
HTTP Status Codes: Speaking the Language of Web APIs
HTTP status codes are the first line of communication when something goes wrong. Using them correctly is essential for building a professional API.
During a recent code review, I spotted an endpoint that returned a 200 OK status with an error message in the response body. This anti-pattern confuses clients and breaks conventions that other developers expect.
Here’s a quick refresher on the most important status codes for APIs:
2XX – Success
- 200 OK: The request succeeded (for GET requests)
- 201 Created: A new resource was successfully created (for POST requests)
- 204 No Content: The request succeeded, but there’s nothing to return (often used for DELETE)
4XX – Client Errors
- 400 Bad Request: The client sent something invalid (malformed JSON, invalid parameters, etc.)
- 401 Unauthorized: Authentication is required but wasn’t provided
- 403 Forbidden: The client is authenticated but doesn’t have permission
- 404 Not Found: The requested resource doesn’t exist
- 409 Conflict: The request conflicts with the current state (e.g., creating a duplicate resource)
- 422 Unprocessable Entity: The request was well-formed but had semantic errors
5XX – Server Errors
- 500 Internal Server Error: Something unexpected went wrong on the server
- 502 Bad Gateway: An upstream service returned an invalid response
- 503 Service Unavailable: The server is temporarily unavailable (maintenance, overloaded)
I’ve seen too many APIs that return 500 for every error, regardless of the cause. This makes it impossible for clients to handle errors intelligently. Always return the most specific status code that applies to the situation.
Building a Robust Error Handling System in .NET
Let’s dive into practical implementation. I’ll share code examples from real projects (with names changed to protect the innocent) and explain the reasoning behind each approach.
1. Global Exception Handling
The foundation of any good error-handling strategy is global exception handling. This ensures that unhandled exceptions don’t result in default error pages or, worse, expose sensitive information.
I use a middleware approach in all my projects:
ErrorResponse.cs
namespace MyApi.ErrorHandling;
// Define a standard error response model
public class ErrorResponse
{
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int Status { get; set; }
public string TraceId { get; set; } = string.Empty;
public IDictionary<string, string[]> Errors { get; set; } = new Dictionary<string, string[]>();
}
// Custom exceptions
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class BadRequestException : Exception
{
public IDictionary<string, string[]>? ValidationErrors { get; }
public BadRequestException(string message) : base(message) { }
public BadRequestException(string message, IDictionary<string, string[]> validationErrors) : base(message)
{
ValidationErrors = validationErrors;
}
}
ExceptionHandlingExtensions.cs
// Global exception handler
public static class ExceptionHandlingExtensions
{
public static void ConfigureExceptionHandler(this WebApplication app)
{
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
var errorResponse = new ErrorResponse
{
TraceId = context.TraceIdentifier
};
switch (exception)
{
case NotFoundException notFoundException:
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
errorResponse.Status = (int)HttpStatusCode.NotFound;
errorResponse.Title = "Resource not found";
errorResponse.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
break;
case BadRequestException badRequestException:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
errorResponse.Status = (int)HttpStatusCode.BadRequest;
errorResponse.Title = "One or more validation errors occurred";
errorResponse.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1";
if (badRequestException.ValidationErrors != null)
{
errorResponse.Errors = badRequestException.ValidationErrors;
}
break;
case UnauthorizedAccessException:
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
errorResponse.Status = (int)HttpStatusCode.Unauthorized;
errorResponse.Title = "Authentication required";
errorResponse.Type = "https://tools.ietf.org/html/rfc7235#section-3.1";
break;
default:
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
errorResponse.Status = (int)HttpStatusCode.InternalServerError;
errorResponse.Title = "An error occurred while processing your request";
errorResponse.Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1";
// Log the exception details here
break;
}
context.Response.ContentType = "application/problem+json";
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, options));
});
});
}
}
Join the conversation! Your thoughts help the community grow.