DEV Community

Florian Lenz
Florian Lenz

Posted on

Standardized Error Messages in .NET REST APIs - Implementing RFC 7807 Problem Details

The Silent Killer: Unhandled Exceptions

Imagine an application fetching user data from an external API. If the API is unavailable and exceptions aren't handled, the application can crash, leading to poor user experience and frustration for developers and users alike.

The Importance of Good Error Messages

Good error messages are crucial for efficient troubleshooting and for helping clients understand what went wrong. Without clear error messages, issues such as confusion, extended development cycles, poor user experience, increased support requests, and loss of trust can arise.

HTTP Status Codes and Error Handling

Understanding and correctly using HTTP status codes is key to effective API error handling. They help communicate the status and nature of the error to the client, enabling targeted troubleshooting.

2xx Success

  • 200 OK: Request was successful.
  • 204 No Content: Request was successful, but no content to return.
  • 202 Accepted: Request accepted, processing not completed.

4xx Client Errors

  • 400 Bad Request: The request was invalid or cannot be processed.
  • 401 Unauthorized: Authentication is required and has failed or not been provided.
  • 403 Forbidden: The server understands the request but refuses to authorize it.
  • 404 Not Found: The requested resource could not be found.

5xx Server Errors

  • 500 Internal Server Error: A generic error for unexpected server issues.
  • 502 Bad Gateway: Invalid response from an upstream server.
  • 503 Service Unavailable: The server is currently unavailable.
  • 504 Gateway Timeout: The server didn't receive a timely response from an upstream server.

Domain-Driven Design (DDD) and Error Handling

It's important to distinguish between domain errors (business logic) and application errors (technical problems). This distinction helps in choosing the correct status codes and clearly communicating where the problem lies.

Domain Exceptions
Domain exceptions occur when business rules are violated and should typically return 4xx status codes. Examples include:

ValidationException: Invalid data sent by the client.

{
  "type": "https://example.com/probs/validation",
  "title": "Invalid request parameters",
  "status": 400,
  "detail": "The provided data is invalid. Please check the following fields.",
  "instance": "/api/bookings",
  "errors": {
    "startDate": "The start date must be in the future.",
    "endDate": "The end date must be after the start date.",
    "roomNumber": "The specified room number does not exist."
  }
}
Enter fullscreen mode Exit fullscreen mode

EntityNotFoundException: The requested entity does not exist.

{
  "type": "https://example.com/probs/entity-not-found",
  "title": "Entity not found",
  "status": 404,
  "detail": "The booking ID '98765' was not found.",
  "instance": "/api/bookings/98765"
}
Enter fullscreen mode Exit fullscreen mode

BusinessRuleViolationException: A business rule was violated.

{
  "type": "https://example.com/probs/business-rule-violation",
  "title": "Business rule violation",
  "status": 409,
  "detail": "The booking cannot be created as the room is already occupied for the specified period.",
  "instance": "/api/bookings"
}
Enter fullscreen mode Exit fullscreen mode

Application Exceptions

Application exceptions relate to technical problems or unexpected errors in the application code and should return 5xx status codes. Examples include:

TimeoutException: A timeout occurred, e.g., in a database query.

{
  "type": "https://example.com/probs/timeout",
  "title": "Request timeout",
  "status": 504,
  "detail": "The request timed out. Please try again later.",
  "instance": "/api/bookings",
  "timestamp": "2024-06-30T12:34:56Z"
}
Enter fullscreen mode Exit fullscreen mode

IOException: An I/O error, e.g., accessing the file system.

{
  "type": "https://example.com/probs/io-error",
  "title": "I/O error",
  "status": 500,
  "detail": "An error occurred while accessing the file system. Please try again later.",
  "instance": "/api/files/upload",
  "timestamp": "2024-06-30T12:34:56Z"
}
Enter fullscreen mode Exit fullscreen mode

DatabaseException: A database connection or query error.

{
  "type": "https://example.com/probs/database-error",
  "title": "Database error",
  "status": 500,
  "detail": "An error occurred while connecting to the database. Please try again later.",
  "instance": "/api/bookings",
  "timestamp": "2024-06-30T12:34:56Z"
}
Enter fullscreen mode Exit fullscreen mode

Why This Distinction Matters

Distinguishing between domain and application exceptions is crucial for clear communication and efficient error handling:

  1. Accurate Error Diagnosis: Specific status codes and error types help clients understand whether the problem is on their side (4xx) or the server side (5xx).
  2. Targeted Error Resolution: Domain exceptions provide clear guidance on what inputs or business rules need adjustment. Application exceptions indicate technical issues requiring server-side fixes.
  3. Improved User Experience: Clear and precise error messages enable users and developers to react and resolve issues more quickly.
  4. Efficiency and Stability: Accurate error handling improves the efficiency of development and support teams and enhances overall application stability.

Implementing ProblemDetails for Error Handling in .NET Core
After understanding the importance of HTTP status codes and the distinction between domain and application exceptions, let's see how to implement these principles in a .NET Core application.

Define Domain Exceptions

In a Domain-Driven Design (DDD) architecture, it's useful to define specific domain exceptions that inherit from a generic DomainException. These exceptions can then be processed correctly in middleware and transformed into standardized HTTP responses using the ProblemDetails class.

Step 1: Define Domain Exceptions
Create a base class DomainException and specific domain exceptions that inherit from it:

public abstract class DomainException : Exception
{
    protected DomainException(string message) : base(message) { }
}

public class ValidationException : DomainException
{
    public IDictionary<string, string[]> Errors { get; }

    public ValidationException(string message, IDictionary<string, string[]> errors) : base(message)
    {
        Errors = errors;
    }
}

public class EntityNotFoundException : DomainException
{
    public EntityNotFoundException(string message) : base(message) { }
}

public class BusinessRuleViolationException : DomainException
{
    public BusinessRuleViolationException(string message) : base(message) { }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create Middleware for Error Processing
Create a middleware class that catches these domain exceptions and transforms them into ProblemDetails responses:

public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionMiddleware> _logger;

    public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        try
        {
            await _next(httpContext);
        }
        catch (DomainException ex)
        {
            _logger.LogError($"A domain exception occurred: {ex.Message}");
            await HandleDomainExceptionAsync(httpContext, ex);
        }
        catch (Exception ex)
        {
            _logger.LogError($"An unexpected error occurred: {ex.Message}");
            await HandleExceptionAsync(httpContext, ex);
        }
    }

    private static Task HandleDomainExceptionAsync(HttpContext context, DomainException exception)
    {
        ProblemDetails problemDetails = exception switch
        {
            ValidationException validationEx => new ValidationProblemDetails(validationEx.Errors)
            {
                Title = "Invalid request parameters",
                Status = StatusCodes.Status400BadRequest,
                Detail = exception.Message,
                Instance = context.Request.Path
            },
            EntityNotFoundException => new ProblemDetails
            {
                Title = "Entity not found",
                Status = StatusCodes.Status404NotFound,
                Detail = exception.Message,
                Instance = context.Request.Path
            },
            BusinessRuleViolationException => new ProblemDetails
            {
                Title = "Business rule violation",
                Status = StatusCodes.Status409Conflict,
                Detail = exception.Message,
                Instance = context.Request.Path
            },
            _ => new ProblemDetails
            {
                Title = "Domain error",
                Status = StatusCodes.Status400BadRequest,
                Detail = exception.Message,
                Instance = context.Request.Path
            }
        };

        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = problemDetails.Status ?? StatusCodes.Status400BadRequest;
        return context.Response.WriteAsJsonAsync(problemDetails);
    }

    private static Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var problemDetails = new ProblemDetails
        {
            Title = "An unexpected error occurred",
            Status = StatusCodes.Status500InternalServerError,
            Detail = exception.Message,
            Instance = context.Request.Path
        };

        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        return context.Response.WriteAsJsonAsync(problemDetails);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Register Middleware
Register the middleware in your Startup or Program file:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseMiddleware<ExceptionMiddleware>();

    app.UseRouting();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}
Enter fullscreen mode Exit fullscreen mode

By following these steps, you can implement standardized error messages in your .NET Core application, improving both developer and user experience by providing clear and actionable error information. This approach not only enhances communication but also aligns with the principles of Domain-Driven Design (DDD) and ensures your application adheres to the RFC 7807 Problem Details specification.

Resources

Top comments (0)