Evolution of ProblemDetails: From .NET Core 2.2 to .NET 9

Date
Authors

Introduction

When building APIs in .NET, proper error handling is crucial for both developers consuming your API and for debugging purposes. Before the introduction of ProblemDetails, ASP.NET Core had a very basic approach to error responses that provided minimal information to API consumers.

Let's explore how exception handling worked in traditional ASP.NET Core controllers and minimal APIs before the widespread adoption of ProblemDetails.

Default Exception Handling Behavior

ASP.NET Core handles exceptions differently between development and production environments. In production (which is our focus here), the framework provides very minimal error information by default for security reasons.

Unhandled Exceptions

When an unhandled exception occurs in your API endpoint, ASP.NET Core returns a 500 Internal Server Error response with no body:

Controller Approach:

// ProductsController.cs
[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id)
    {
        throw new Exception("Something went wrong!");
    }
}

Minimal API Approach:

// Program.cs
app.MapGet("/products/{id}", (int id) =>
{
    throw new Exception("Something went wrong!");
});

These would return:

HTTP/1.1 500 Internal Server Error
Content-Length: 0
Connection: close
Date: Mon, 24 Jul 2023 13:52:12 GMT
Server: Kestrel

This response tells the client that something went wrong, but provides no details about what specifically failed.

Handled Exceptions

When developers catch and handle exceptions themselves (for example, using try-catch blocks), they can return specific status codes, but often without additional context:

Controller Approach:

[HttpGet("products/{id}")]
public IActionResult GetSafe(int id)
{
    try
    {
        if (id > 100)
            throw new ArgumentException("ID too large");
        return Ok(new { Id = id });
    }
    catch (Exception)
    {
        return BadRequest();
    }
}

Minimal API Approach:

app.MapGet("products/safe/{id}", (int id) =>
{
    try
    {
        if (id > 100)
            throw new ArgumentException("ID too large");
        return Results.Ok(new { Id = id });
    }
    catch (Exception)
    {
        return Results.BadRequest();
    }
});

These would return:

HTTP/1.1 400 Bad Request
Content-Length: 0
Connection: close
Date: Mon, 24 Jul 2023 14:08:16 GMT
Server: Kestrel

Again, while the status code is more specific (400 instead of 500), there's still no information about what exactly was wrong with the request.

Common Scenarios with Both Approaches

Let's look at some common scenarios and their responses before ProblemDetails.

1. Not Found Scenario

Controller Approach:

[HttpGet("products/{id}")]
public IActionResult FindProduct(int id)
{
    var product = _repository.Find(id); // returns null if not found
    if (product == null)
    {
        return NotFound();
    }
    return Ok(product);
}

Minimal API Approach:

app.MapGet("/products/{id}", (int id) =>
{
    var product = _repository.Find(id);
    return product == null ? Results.NotFound() : Results.Ok(product);
});

Response:

HTTP/1.1 404 Not Found
Content-Length: 0

2. Validation Failure

Controller Approach:

[HttpPost("products")]
public IActionResult Create(Product product)
{
    if (string.IsNullOrEmpty(product.Name))
    {
        return BadRequest("Product name is required");
    }

    // ... save logic

    return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
}

Minimal API Approach:

app.MapPost("products", (Product product) =>
{
    if (string.IsNullOrEmpty(product.Name))
    {
        return Results.BadRequest("Product name is required");
    }

    // ... save logic

    return Results.Created($"/products/{product.Id}", product);
});

Response:

HTTP/1.1 400 Bad Request
Content-Type: text/plain
Content-Length: 23

Product name is required

While this provides an error message, the format is inconsistent (plain text) and doesn't follow any standard.

3. Custom Status Code with Message

Controller Approach:

[HttpGet("products/check/{id}")]
public IActionResult CheckProduct(int id)
{
    if (id < 0)
    {
        return StatusCode(422, "ID must be positive");
    }
    return Ok();
}

Minimal API Approach:

app.MapGet("products/check/{id}", (int id) =>
{
    if (id < 0)
    {
        return Results.StatusCode(422, "ID must be positive");
    }
    return Results.Ok();
});

Response:

HTTP/1.1 422 Unprocessable Entity
Content-Type: text/plain
Content-Length: 19

ID must be positive

Limitations of the Default Behavior

  1. Inconsistent formats: Some errors might return plain text, others JSON, and some no content at all.
  2. No standard structure: Clients have to parse errors differently for different endpoints.
  3. Limited information: Critical debugging information is missing (error types, additional details, etc.).
  4. No machine-readable details: Automated clients can't easily parse and handle errors consistently.

The Need for ProblemDetails

This inconsistency and lack of detail led to the creation of the ProblemDetails specification (RFC 7807), which provides a standardized way to report errors in HTTP APIs. In the next article, we'll explore how ProblemDetails solves these issues and how to implement it in your .NET APIs.

The key improvements ProblemDetails brings are:

  • Standardized error response format
  • Rich error information
  • Consistent handling across all endpoints
  • Machine-readable error details
  • Extensibility for custom error information

By adopting ProblemDetails, you can significantly improve the developer experience for anyone consuming your API and make error handling more consistent and maintainable in your own codebase.

What are Problem Details?

The ProblemDetails type is based on the RFC 7807 specification for providing a unified, machine-readable, standardized recipe for exposing error information out of your HTTP APIs, which is of course beneficial both for the API authors and api consumers like third parties. ProblemDetails class introduced in ASP.NET Core 2.1 and It was possible to return the Problem Details response manually in our controller or by using some limited functionality in the ControllerBase class like ControllerBase.ValidationProblem() method and ControllerBase.Problem() which is available after ASP.NET Core 3.0, also in .Net 6 and minimal apis creating ProblemDetails is possible with using Results.Problem() and Results.ValidationProblem().

The ProblemDetails class includes these standard properties:

  • Type (URI identifying the problem type)
  • Title (human-readable summary)
  • Status (HTTP status code)
  • Detail (human-readable explanation)
  • Instance (URI identifying the specific occurrence)

Using ProblemDetails in Controllers

1. Basic Problem Response

[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id)
    {
        if (id > 1000)
        {
            return Problem(
                title: "Invalid ID",
                detail: "Product ID must be less than 1000",
                statusCode: StatusCodes.Status400BadRequest,
                type: "https://example.com/errors/invalid-id");
        }

        return Ok(new { Id = id, Name = "Sample Product" });
    }
}

Response:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://example.com/errors/invalid-id",
  "title": "Invalid ID",
  "status": 400,
  "detail": "Product ID must be less than 1000"
}

2. Validation Problem

[HttpPost]
public IActionResult Create([FromBody] Product product)
{
    if (!ModelState.IsValid)
    {
        return ValidationProblem(ModelState);
    }

    // Save product logic

    return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
}

Response:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": ["The Name field is required."],
    "Price": ["The field Price must be between 1 and 1000."]
  }
}

Using ProblemDetails in Minimal APIs

1. Basic Problem Response

app.MapGet("products/{id}", (int id) =>
{
    if (id > 1000)
    {
        return Results.Problem(
            title: "Invalid ID",
            detail: "Product ID must be less than 1000",
            statusCode: StatusCodes.Status400BadRequest,
            type: "https://example.com/errors/invalid-id");
    }

    return Results.Ok(new { Id = id, Name = "Sample Product" });
});

Response: (Same as controller example)

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://example.com/errors/invalid-id",
  "title": "Invalid ID",
  "status": 400,
  "detail": "Product ID must be less than 1000"
}

2. Validation Problem

app.MapPost("products", (Product product) =>
{
    var errors = new Dictionary<string, string[]>();

    if (string.IsNullOrEmpty(product.Name))
    {
        errors.Add(nameof(product.Name), new[] { "Product name is required" });
    }

    if (product.Price <= 0)
    {
        errors.Add(nameof(product.Price), new[] { "Price must be positive" });
    }

    if (errors.Any())
    {
        return Results.ValidationProblem(errors);
    }

    // Save product logic

    return Results.Created($"/products/{product.Id}", product);
});

Response:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": ["Product name is required"],
    "Price": ["Price must be positive"]
  }
}

3. Custom Problem Details

return Results.Problem(
    title: "Out of Stock",
    detail: "This product is currently unavailable",
    statusCode: StatusCodes.Status422UnprocessableEntity,
    type: "https://example.com/errors/out-of-stock",
    extensions: new Dictionary<string, object?>
    {
        { "productId", id },
        { "estimatedRestock", "2023-12-01" }
    });

Response:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://example.com/errors/out-of-stock",
  "title": "Out of Stock",
  "status": 422,
  "detail": "This product is currently unavailable",
  "productId": 123,
  "estimatedRestock": "2023-12-01"
}

Handling Exception With Problem Details Before .Net 7 Preview 7

After ASP.NET Core 2.2 until .Net 7 preview 7 , there are some cases where .net frameworks generates a ProblemDetails object automatically with the ApiController attribute. Actually, with the ApiController attribute, we can use some of the good api facilities, like automatic HTTP 400 responses, binding source parameter inference, and problem details for error status codes. In ASP.NET Core 2.2 and later until .Net 7 preview 7 when we have ApiController attribute and create an error result like ConflictResult or NotFoundResult for status codes >= 400 but without having a run time exception, MVC creates a problem details object automatically for our response with using ClientErrorResultFilter and internally for status codes => 400 use of ProblemDetailsClientErrorFactory and finally ProblemDetailsFactory class to create problem details object.

NET 7 Preview 7 introduced a new problem details service based on the IProblemDetailsService interface for generating consistent problem details responses in your app. I will cover this later after explanation .net mechanism before this update.

Note: Before .Net 7 preview 7 using package Hellang.Middleware.ProblemDetails was very popular for handling exceptions and converting them to problem details automatically through its middleware.

Automatic ProblemDetails Generation

How It Worked

When you decorated your controller with [ApiController], ASP.NET Core would automatically:

  1. Convert error status codes (≥400) to ProblemDetails responses (All errors from MVC controllers, whether they're ≥400, return a ProblemDetails object)
  2. Handle model validation errors with detailed ProblemDetails
  3. Apply consistent error formatting across all endpoints
  4. However, if your application throws an exception, you don't get a ProblemDetails response and you should create an ExceptionHandler middleware for converting exception to ProblemDetails object in the response and A better option is to use an existing NuGet package Hellang.Middleware.ProblemDetails that handles it for you.

Controller Example (Pre-.NET 7):

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpGet("{id}")]
    public ActionResult<Order> GetOrder(int id)
    {
        if (id <= 0)
        {
            // Before .NET 7, this would automatically be converted to ProblemDetails when we use `ApiController` attribute
            return BadRequest();
        }

        var order = _repository.GetOrder(id);
        if (order == null)
        {
            // This 404 response would automatically include ProblemDetails when we use `ApiController` attribute
            return NotFound();
        }

        return order;
    }

    [HttpPost]
    public IActionResult CreateOrder([FromBody] Order order)
    {
        // Model validation errors would automatically generate
        // detailed validation ProblemDetails
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        // ... order creation logic

        return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    }
}

Response for GET /api/orders/0 (400 Bad Request):

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "Bad Request",
  "status": 400,
  "traceId": "00-abcdef1234567890abcdef1234567890-9876543210fedcba-00"
}

Response for GET /api/orders/9999 (404 Not Found):

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-abcdef1234567890abcdef1234567890-9876543210fedcba-00"
}

Note: Before .NET 7, Minimal APIs didn't have automatic ProblemDetails generation. You had to manually create them.

Key Components Behind the Scenes

The automatic ProblemDetails generation relied on several key components:

  1. ClientErrorResultFilter: Intercepted results with status codes ≥400
  2. ProblemDetailsClientErrorFactory: Determined when to create ProblemDetails
  3. ProblemDetailsFactory: The actual factory creating ProblemDetails instances

Customizing ProblemDetails (Pre-.NET 7)

You could customize the default behavior by:

// In Startup.cs or Program.cs
services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        // Customize the automatic ProblemDetails
        options.ClientErrorMapping[404].Link = "https://example.com/errors/not-found";
        options.ClientErrorMapping[404].Title = "Resource Not Found";

        // Custom ProblemDetails factory
        options.ProblemDetailsFactory = new CustomProblemDetailsFactory();
    });

public class CustomProblemDetailsFactory : ProblemDetailsFactory
{
    public override ProblemDetails CreateProblemDetails(
        HttpContext httpContext,
        int? statusCode = null,
        string? title = null,
        string? type = null,
        string? detail = null,
        string? instance = null)
    {
        var problemDetails = new ProblemDetails
        {
            Status = statusCode ?? 500,
            Title = title,
            Type = type,
            Detail = detail,
            Instance = instance
        };

        // Add custom extensions
        problemDetails.Extensions.Add("timestamp", DateTime.UtcNow);
        problemDetails.Extensions.Add("serviceVersion", "1.0.0");

        return problemDetails;
    }
}

Handling Exceptions with ProblemDetails After .NET 7 Preview 7 and Before .NET 8

With .NET 7 Preview 7, Microsoft introduced a significant improvement to error handling through the IProblemDetailsService interface. This new service provides a standardized way to generate RFC 7807-compliant Problem Details responses throughout your application.

Key Benefits:

  • Consistent error responses across all layers of your app
  • Centralized configuration of problem details format
  • Flexible customization options
  • Automatic integration to generate problem details for exception through UseExceptionHandler, UseStatusCodePages and UseDeveloperExceptionPage middlewares

Basic Setup ProblemDetails With IServiceCollection.AddProblemDetails()

To enable the Problem Details service:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddProblemDetails();

var app = builder.Build();

This simple registration:

  • Adds the default IProblemDetailsService implementation which is ProblemDetailsService and the default IProblemDetailsWriter implementation which is DefaultProblemDetailsWriter
  • Configures the middleware pipeline through ProblemDetailsOptions to handle errors consistently

Using IProblemDetailsService

You can generate ProblemDetails responses from anywhere in your app that has access to the HTTP context:

From Middleware Example

app.Use(async (httpContext, next) =>
{
    try
    {
        await next();
    }
    catch (Exception ex)
    {
        httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;

        if (httpContext.RequestServices.GetService<IProblemDetailsService>()
            is { } problemDetailsService)
        {
            await problemDetailsService.WriteAsync(new ProblemDetailsContext
            {
                HttpContext = httpContext,
                ProblemDetails =
                {
                    Title = "Invalid request",
                    Detail = ex.Message,
                    Type = "https://example.com/errors/bad-request"
                }
            });
            return;
        }

        // Fallback if ProblemDetails service isn't available
        await httpContext.Response.WriteAsJsonAsync(new
        {
            error = ex.Message
        });
    }
});

Customization ProblemDetails Options

1. Basic Customization

You can customize all ProblemDetails responses globally:

services.AddProblemDetails(c =>
{
    c.CustomizeProblemDetails = context =>
    {
        // with the help of `capture exception middleware` for capturing actual thrown exception, in .net 8 preview 5 it will create automatically
        IExceptionHandlerFeature? exceptionFeature =
            context.HttpContext.Features.Get<IExceptionHandlerFeature>();

        Exception? exception = exceptionFeature?.Error ?? context.Exception;

        var mappers =
            context.HttpContext.RequestServices.GetServices<IProblemDetailMapper>();

        var webHostEnvironment =
            context.HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>();

        // if we throw an exception, we should create appropriate ProblemDetail based on the exception, else we just return default ProblemDetail with status 500 or a custom ProblemDetail which is returned from the endpoint
        CreateProblemDetailFromException(context, webHostEnvironment, exception, mappers);
    };

    configure?.Invoke(c);
});

private static void CreateProblemDetailFromException(
    ProblemDetailsContext context,
    IWebHostEnvironment webHostEnvironment,
    Exception? exception,
    IEnumerable<IProblemDetailMapper>? problemDetailMappers
)
{
    var traceId = Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;

    int statusCode =
        problemDetailMappers?.Select(m => m.GetMappedStatusCodes(exception)).FirstOrDefault()
        ?? new DefaultProblemDetailMapper().GetMappedStatusCodes(exception);

    context.HttpContext.Response.StatusCode = statusCode;

    context.ProblemDetails = PopulateNewProblemDetail(
        statusCode,
        context.HttpContext,
        webHostEnvironment,
        exception,
        traceId
    );
}

private static ProblemDetails PopulateNewProblemDetail(
    int code,
    HttpContext httpContext,
    IWebHostEnvironment webHostEnvironment,
    Exception? exception,
    string traceId
)
{
    var extensions = new Dictionary<string, object?> { { "traceId", traceId } };

    // Add stackTrace in development mode for debugging purposes
    if (webHostEnvironment.IsDevelopment() && exception is { })
    {
        extensions["stackTrace"] = exception.StackTrace;
    }

    // type will fill automatically by .net core
    var problem = TypedResults
        .Problem(
            statusCode: code,
            detail: exception?.Message,
            title: exception?.GetType().Name,
            instance: $"{httpContext.Request.Method} {httpContext.Request.Path}",
            extensions: extensions
        )
        .ProblemDetails;

    return problem;
}

2. Advanced Customization with IProblemDetailsWriter and IProblemDetailsService

For more control, we can implement a custom problem detail writer like this:

public class CustomProblemDetailsWriter(IOptions<ProblemDetailsOptions> options) : IProblemDetailsWriter
{
    private readonly ProblemDetailsOptions _options = options.Value;

    public ValueTask WriteAsync(ProblemDetailsContext context)
    {
        var httpContext = context.HttpContext;

        // Apply final customization (if any)
        _options.CustomizeProblemDetails?.Invoke(context);

        // Ensure required fields are set
        context.ProblemDetails.Status ??= httpContext.Response.StatusCode;

        // Write JSON response
        return new ValueTask(
            httpContext.Response.WriteAsJsonAsync(
                context.ProblemDetails,
                options: null,
                contentType: "application/problem+json"
            )
        );
    }

    public bool CanWrite(ProblemDetailsContext context)
    {
        return true;
    }
}

And we can create a custom ProblemDetailsService like this:

public class CustomProblemDetailsService(
    IEnumerable<IProblemDetailsWriter> writers,
    IWebHostEnvironment webHostEnvironment,
    ILogger<ProblemDetailsService> logger,
    IEnumerable<IProblemDetailMapper>? problemDetailMappers
) : IProblemDetailsService
{
    public ValueTask WriteAsync(ProblemDetailsContext context)
    {
        ArgumentNullException.ThrowIfNull(context, nameof(context));
        ArgumentNullException.ThrowIfNull(context.ProblemDetails);
        ArgumentNullException.ThrowIfNull(context.HttpContext);

        // Skip if response already started or status code < 400
        if (
            context.HttpContext.Response.HasStarted
            || context.HttpContext.Response.StatusCode < 400
        )
        {
            return ValueTask.CompletedTask;
        }

        // with the help of `capture exception middleware` for capturing actual thrown exception, in .net 8 preview 5 it will create automatically
        IExceptionHandlerFeature? exceptionFeature =
            context.HttpContext.Features.Get<IExceptionHandlerFeature>();

        Exception? exception = exceptionFeature?.Error ?? context.Exception;

        // if we throw an exception, we should create appropriate ProblemDetail based on the exception, else we just return default ProblemDetail with status 500 or a custom ProblemDetail which is returned from the endpoint
        CreateProblemDetailFromException(context, exception);

        // Write using the best-matched writer
        foreach (var writer in writers)
        {
            if (writer.CanWrite(context))
            {
                return writer.WriteAsync(context);
            }
        }

        logger.LogWarning("No suitable IProblemDetailsWriter found for the current context.");

        return ValueTask.CompletedTask;
    }

    private void CreateProblemDetailFromException(
        ProblemDetailsContext context,
        Exception? exception
    )
    {
        var traceId = Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;

        if (exception is { })
        {
            logger.LogError(
                exception,
                "Could not process a request on machine {MachineName}. TraceId: {TraceId}",
                Environment.MachineName,
                traceId
            );
        }

        int statusCode =
            problemDetailMappers?.Select(m => m.GetMappedStatusCodes(exception)).FirstOrDefault()
            ?? new DefaultProblemDetailMapper().GetMappedStatusCodes(exception);

        context.HttpContext.Response.StatusCode = statusCode;

        context.ProblemDetails = PopulateNewProblemDetail(
            statusCode,
            context.HttpContext,
            exception,
            traceId
        );
    }

    private ProblemDetails PopulateNewProblemDetail(
        int code,
        HttpContext httpContext,
        Exception? exception,
        string traceId
    )
    {
        var extensions = new Dictionary<string, object?> { { "traceId", traceId } };

        // Add stackTrace in development mode for debugging purposes
        if (webHostEnvironment.IsDevelopment() && exception is { })
        {
            extensions["stackTrace"] = exception.StackTrace;
        }

        // type will fill automatically by .net core
        var problem = TypedResults
            .Problem(
                statusCode: code,
                detail: exception?.Message,
                title: exception?.GetType().Name,
                instance: $"{httpContext.Request.Method} {httpContext.Request.Path}",
                extensions: extensions
            )
            .ProblemDetails;

        return problem;
    }
}

Register your custom ProblemDetailsWriter and ProblemDetailsService:

// Must be registered BEFORE AddProblemDetails because AddProblemDetails internally uses TryAddSingleton for adding default implementation for IProblemDetailsWriter
builder.Services.AddSingleton<IProblemDetailsWriter, CustomProblemDetailsWriter>();
builder.Services.AddSingleton<IProblemDetailsService, CustomProblemDetailsService>();

builder.Services.AddProblemDetails();

ASP.NET Core Error Handling Middlewares Updates for .Net 7 preview 7

The release of .NET 7 Preview 7 brought significant improvements to ASP.NET Core's error handling capabilities, particularly around ProblemDetails generation when the new problem details service (IProblemDetailsService) is registered. These updates modernized the error handling pipeline and unified the behavior across different types of ASP.NET Core applications.

1. ExceptionHandlerMiddleware Middleware Updates

Catches and handles uncaught exceptions that bubble up through the middleware pipeline in production.

Core Behavior Before .NET 7:

  1. Basic Error Capturing:

    • Caught unhandled exceptions bubbling up the middleware pipeline
    • Prevented server crashes by gracefully handling exceptions
    • Allowed redirection to error handling paths (e.g., /error)
  2. Response Generation:

    • By default, returned plain text responses or empty responses with just status codes
    • Required manual configuration to produce structured error responses
    • Did not automatically generate ProblemDetails responses
  3. Typical Pre-.NET 7 Configuration:

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        // Manual response creation
        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsync(JsonSerializer.Serialize(new
        {
            Error = "An error occurred",
            RequestId = context.TraceIdentifier
        }));
    });
});

What's New in .NET 7 for ExceptionHandlerMiddleware:

  • Now integrates with IProblemDetailsService for automatic ProblemDetails generation
  • Middleware updates for UseExceptionHandler, UseStatusCodePages and UseDeveloperExceptionPage middlewares to automatically generate problem details
  • Supports writing consistent error responses for uncaught exceptions
  • Works seamlessly with both controllers and minimal APIs

How to Use UseExceptionHandler in .NET 7+:

builder.Services.AddProblemDetails(); // Required for auto ProblemDetails
var app = builder.Build();

app.UseExceptionHandler(); // Now generates ProblemDetails

Produces responses like:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "Internal Server Error",
  "status": 500,
  "traceId": "00-0aa7d64ad154a1e1853c413a0def982d-195d3558c90f7876-00"
}

When to Use:

  • Essential for production environments without adding error stack trace
  • When you need to gracefully handle unexpected errors using ProblemDetails format
  • When you want consistent error responses across your API

Key Limitations:

  • Only handles uncaught exceptions (not status code responses - need UseStatusCodePages() for handling those and convert to problem details)
  • Provides basic error info unless customized
  • Doesn't handle special exception types differently by default (all become 500 errors) unless we create a custom implementation IProblemDetailsService and map exception types to correct status code

2. UseStatusCodePages Update - For HTTP Status Code Responses

Handles responses that have error status codes (400-599) but no response body.

Before .NET 7 preview 7:

  • Converting empty status code responses (400-599) to simple HTML pages
  • Converting Status codes that weren't caused by exceptions
  • Lacked built-in ProblemDetails support
  • Required manual configuration for API scenarios

Key Updates in UseStatusCodePages .NET 7 Preview 7:

  1. Native ProblemDetails Integration:

    • Automatically generates RFC 7807-compliant problem details responses based on status code when AddProblemDetails() is used
    • Works seamlessly with both controllers and minimal APIs
  2. Improved Content Negotiation:

    • Respects Accept headers (returns JSON/ProblemDetails for APIs, HTML for browsers)
    • Supports application/problem+json content type
  3. Unified Error Handling:

    • hares the same ProblemDetailsOptions configuration as exception handling
    • Consistent behavior across the entire error pipeline

How to Use UseStatusCodePages in .NET 7+:

builder.Services.AddProblemDetails(); // Required for ProblemDetails generation
var app = builder.Build();

app.UseStatusCodePages(); // Enable status code handling and problem details

Produces responses like:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-0aa7d64ad154a1e1853c413a0def982d-195d3558c90f7876-00"
}

When to use:

  1. API Applications

    • Ensures all error status codes (400-599) return structured ProblemDetails responses
    • Provides consistent error formats for API consumers
  2. Handling Framework-Generated Errors

    • Automatically converts empty status code responses (e.g., 404 for missing routes, 403 for denied access)
    • Captures cases where no exception is thrown but an error status is returned
  3. Unified Error Reporting

    • Works alongside UseExceptionHandler to cover all error scenarios:
      • UseExceptionHandler: Catches thrown exceptions (500 errors)
      • UseStatusCodePages: Handles non-exception status codes (404, 401, etc.)

Should We Use UseStatusCodePages After UseExceptionHandler together? Yes, in most cases. Because these middleware components serve complementary purposes and together provide complete error coverage for ASP.NET Core apps.

Why Combine Them?

  1. They Handle Different Error Types

    • UseExceptionHandler → Catches unhandled exceptions (500 errors, crashes)
    • UseStatusCodePages → Handles HTTP status codes (404, 401, 403, etc.)
  2. Prevents "Naked" Status Codes

    • Without UseStatusCodePages, a 404 Not Found might return an empty response.
    • With it, all errors get structured ProblemDetails (if AddProblemDetails() is used).
  3. Consistent API Error Responses

    • Both can be configured to return RFC 7807 ProblemDetails for uniform error formatting.
  4. No Overlap

    • UseExceptionHandler runs first (catching exceptions), while UseStatusCodePages catches remaining status codes.

Recommended Setup:

// Program.cs

// Enable ProblemDetails for APIs
builder.Services.AddProblemDetails();

// Middleware order matters!
app.UseExceptionHandler();   // 1️⃣ Catch exceptions (500s)
app.UseStatusCodePages();    // 2️⃣ Catch status codes (404, 401, etc.)

// Other middleware...

2. UseDeveloperExceptionPage Update

A middleware that provides detailed error pages during development, showing:

  • Stack traces with source code context
  • Request headers/cookies/query strings
  • Exception details (type, message, inner exceptions)

Before .NET 7 preview 7:

  1. HTML-Only Output
    • Generated rich HTML error pages, but no JSON/ProblemDetails support.
  2. Limited Integration
    • Didn’t populate IExceptionHandlerFeature consistently (required workarounds like CaptureExceptionMiddleware).
  3. Manual Configuration
    • Customizations were limited to DeveloperExceptionPageOptions:
app.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions {
    SourceCodeLineCount = 10
});

Key Updates UseDeveloperExceptionPage in .NET 7 preview 7:

  1. Generate problem details response for exceptions containing stack trace
  2. Enhanced Diagnostics
    • Shows ASP.NET Core-specific diagnostics (endpoint routing, middleware pipeline).

How to Use in .NET 7+:

app.UseExceptionHandler();
app.UseStatusCodePages();

//Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

Customization Example:

app.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions
{
    SourceCodeLineCount = 15,  // Show more code context
    ShowExceptionDetails = true
});

Sample response:

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Object reference not set to an instance of an object.",
  "traceId": "00-abc123def456-789ghi012jkl-00",
  "stackTrace": [
    "at MyApp.Controllers.HomeController.Index() in HomeController.cs:line 47",
    "at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute()"
  ]
}

When to Use:

  • Development mode
  • Debugging stack traces
  • Request/response inspection
  • Quick diagnosis of errors

The Middleware Gap Before .NET 8 preview 5

Despite these improvements, there remained a gap in the exception handling pipeline that required a custom middleware solution before .NET 8 Preview 5.

The Problem

When we use UseDeveloperExceptionPage, the DeveloperExceptionPageMiddleware doesn't properly populate the IExceptionHandlerFeature and IExceptionHandlerPathFeature features. This meant that:

  1. Exception details weren't consistently available to subsequent middleware
  2. The error handling pipeline couldn't properly inspect the original exception
  3. Some diagnostic information was lost in production

The Solution: CaptureExceptionMiddleware

To bridge this gap, developers needed to implement a custom middleware:

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

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

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception e)
        {
            CaptureException(e, context);
            throw;
        }
    }

    private static void CaptureException(Exception exception, HttpContext context)
    {
        var instance = new ExceptionHandlerFeature
        {
            Path = context.Request.Path,
            Error = exception
        };
        context.Features.Set<IExceptionHandlerPathFeature>(instance);
        context.Features.Set<IExceptionHandlerFeature>(instance);
    }
}

public static class CaptureExceptionMiddlewareExtensions
{
    public static IApplicationBuilder UseCaptureException(this IApplicationBuilder app)
    {
        if (app == null)
        {
            throw new ArgumentNullException(nameof(app));
        }

        return app.UseMiddleware<CaptureExceptionMiddlewareImp>();
    }
}

Configuration in Program.cs:

Here's how you would configure the pipeline with this middleware:

// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling
// Does nothing if a response body has already been provided. When the next middleware (`DeveloperExceptionPageMiddleware`)
// writes a response for exceptions (in dev mode), the `ExceptionHandlerMiddlewareImpl` won't execute because `context.Response.HasStarted` will be true.
// By default, `ExceptionHandlerMiddlewareImpl` registers original exceptions with the `IExceptionHandlerFeature` feature.
// This doesn't happen in `DeveloperExceptionPageMiddleware`, so we need to handle it with middleware like `CaptureExceptionMiddleware`.
// https://github.com/dotnet/aspnetcore/pull/26567
app.UseExceptionHandler(options: new ExceptionHandlerOptions { AllowStatusCode404Response = true });

if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("test"))
{
    app.UseDeveloperExceptionPage();

    // https://github.com/dotnet/aspnetcore/issues/4765
    // https://github.com/dotnet/aspnetcore/pull/47760
    // The capture exception middleware helps capture the actual exception and populate the `IExceptionHandlerFeature` interface.
    // This was fixed in .NET 8 Preview 5, making the capture exception middleware unnecessary.
    // .NET 8 adds `IExceptionHandlerFeature` in the `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods
    // of the `DeveloperExceptionPageMiddlewareImpl` class, replicating the functionality of CaptureException.
    // Before .NET 8 Preview 5, we need to manually add `IExceptionHandlerFeature` using our `UseCaptureException`.
    app.UseCaptureException();
}

Now we can access to original exception through IExceptionHandlerFeature thanks to CaptureExceptionMiddleware:

services.AddProblemDetails(x =>
{
    x.CustomizeProblemDetails = problemDetailContext =>
    {
        // with help of capture exception middleware for capturing actual exception
        // https://github.com/dotnet/aspnetcore/issues/4765
        // https://github.com/dotnet/aspnetcore/pull/47760
        // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, similar functionality of CaptureException
        // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException`
        if (problemDetailContext.HttpContext.Features.Get<IExceptionHandlerFeature>() is{ } exceptionFeature)
        {
        }
    };
});

Why This Was Necessary:

  • Feature Consistency: Ensured IExceptionHandlerFeature was available in both development and production
  • Diagnostic Preservation: Maintained exception details through the entire pipeline
  • Error Handling Reliability: Made sure error handlers had access to complete exception information
  • Backward Compatibility: Worked around framework limitations until .NET 8's native solution

The .NET 8 preview 5 Solution

This middleware pattern (CaptureExceptionMiddleware) became obsolete with .NET 8 Preview 5, which introduced native support for these features in the DeveloperExceptionPageMiddleware. The framework now automatically:

  • Populates IExceptionHandlerFeature in DisplayExceptionContent
  • Sets exception handler features in SetExceptionHandlerFeatures
  • Provides consistent behavior across all environments

Global Exception Handling in .NET 8 Preview 5 with IExceptionHandler: A Modern Approach

The IExceptionHandler interface, introduced in .NET 8 Preview 5, represents a significant evolution in ASP.NET Core's error handling capabilities. It provides a structured, dependency-injection friendly approach to global exception handling that replaces previous middleware-based solutions. Actually our ExceptionHandlerMiddleware calls our IExceptionHandler and its WriteAsync.

Actually UseExceptionHandler() creates the middleware that catches exceptions and invokes your IExceptionHandler implementations so it should remain in the pipeline to enable the IExceptionHandler system and IExceptionHandler Provides the actual exception handling logic via DI-registered services and it is depend to the middleware created by UseExceptionHandler().

Key Benefits

  • Replacing middleware boilerplate with clean service-based handlers
  • Maintaining middleware infrastructure through UseExceptionHandler()
  • Clean separation of concerns: Exception handling logic is encapsulated in dedicated classes
  • Enabling DI-friendly exception processing with focused handler classes
  • Pipeline control: Can short-circuit middleware pipeline when handling exceptions
  • Multiple handlers: Supports chaining of specialized exception handlers

Key Components

ComponentRole
UseExceptionHandler()Creates the exception-catching middleware
IExceptionHandlerImplements your business logic for errors
AddExceptionHandler<T>()Registers handlers with dependency injection

We can add our custom exception handler like this:

services.AddExceptionHandler<DefaultExceptionHandler>();

Implementation Example

Here's a production-ready handler demonstrating best practices:

public class DefaultExceptionHandler(
    ILogger<DefaultExceptionHandler> logger,
    IWebHostEnvironment webHostEnvironment,
    IEnumerable<IProblemDetailMapper>? problemDetailMappers,
    IProblemDetailsService problemDetailsService
) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken
    )
    {
        logger.LogError(exception, "An unexpected error occurred");

        var problemDetail = CreateProblemDetailFromException(httpContext, exception);

        var context = new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = problemDetail,
        };

        await problemDetailsService.WriteAsync(context);

        return true;
    }

    private ProblemDetails CreateProblemDetailFromException(
        HttpContext context,
        Exception? exception
    )
    {
        var traceId = Activity.Current?.Id ?? context.TraceIdentifier;

        if (exception is { })
        {
            logger.LogError(
                exception,
                "Could not process a request on machine {MachineName}. TraceId: {TraceId}",
                Environment.MachineName,
                traceId
            );
        }

        int statusCode =
            problemDetailMappers?.Select(m => m.GetMappedStatusCodes(exception)).FirstOrDefault()
            ?? new DefaultProblemDetailMapper().GetMappedStatusCodes(exception);

        context.Response.StatusCode = statusCode;

        return PopulateNewProblemDetail(statusCode, context, exception, traceId);
    }

    private ProblemDetails PopulateNewProblemDetail(
        int code,
        HttpContext httpContext,
        Exception? exception,
        string traceId
    )
    {
        var extensions = new Dictionary<string, object?> { { "traceId", traceId } };

        // Add stackTrace in development mode for debugging purposes
        if (webHostEnvironment.IsDevelopment() && exception is { })
        {
            extensions["stackTrace"] = exception.StackTrace;
        }

        // type will fill automatically by .net core
        var problem = TypedResults
            .Problem(
                statusCode: code,
                detail: exception?.Message,
                title: exception?.GetType().Name,
                instance: $"{httpContext.Request.Method} {httpContext.Request.Path}",
                extensions: extensions
            )
            .ProblemDetails;

        return problem;
    }
}

Registration:

// Program.cs
builder.Services.AddProblemDetails(); // Enable RFC 7807 responses
builder.Services.AddExceptionHandler<DefaultExceptionHandler>();
builder.Services.AddSingleton<IProblemDetailMapper, CustomExceptionMapper>();

app.UseExceptionHandler(); // Activate the pipeline

Example Implementations Available on GitHub

For developers looking to see practical implementations of these patterns, I've provided complete working examples in this GitHub repository:

https://github.com/mehdihadeli/blog-samples/tree/main/problem-details

Conclusion: The Journey to Modern Error Handling

Over the years, ASP.NET Core has significantly evolved its approach to error handling, with ProblemDetails and global exception handler becoming increasingly standardized, flexible, and performant. By adopting IExceptionHandler and ProblemDetails, you ensure your application follows modern .NET best practices while maintaining flexibility for future improvements.

From its humble beginnings in .NET Core 2.2 to the sophisticated IExceptionHandler in .NET 8+, we've seen:

  • A shift from ad-hoc error responses to standardized ProblemDetails
  • Migration from custom middleware to DI-friendly handler services
  • Progression from manual status code mapping to pluggable exception processors
  • Advancement from basic error messages to rich, machine-readable diagnostics

By adopting IExceptionHandler and ProblemDetails, you gain:

  1. Consistency - Uniform error formats across your entire API surface
  2. Maintainability - Clean separation of error handling logic from business code
  3. Observability - Built-in correlation IDs and structured logging
  4. Flexibility - Environment-specific details (like stack traces in development)
  5. Future-proofing - Alignment with .NET's ongoing improvements

The evolution from basic try-catch blocks to today's sophisticated handler pipeline demonstrates .NET's maturation as an API platform. As we look toward .NET 9 and beyond, these foundational improvements ensure your error handling strategy will remain robust, maintainable, and aligned with industry best practices.