Global Error Handling & Custom Exceptions

লেসন ৩২ / ৫০

Custom Exception Hierarchy

public abstract class AppException : Exception {
    public abstract int StatusCode { get; }
    public abstract string Code { get; }
    protected AppException(string msg) : base(msg) {}
}

public class NotFoundException : AppException {
    public override int StatusCode => 404;
    public override string Code => "NOT_FOUND";
    public NotFoundException(string name, object id) :
        base($"{name} with id {id} not found") {}
}

public class ValidationException : AppException {
    public override int StatusCode => 422;
    public override string Code => "VALIDATION_FAILED";
    public Dictionary<string, string[]> Errors { get; }
    public ValidationException(Dictionary<string, string[]> errors) :
        base("Validation failed") => Errors = errors;
}

public class ConflictException : AppException {
    public override int StatusCode => 409;
    public override string Code => "CONFLICT";
    public ConflictException(string msg) : base(msg) {}
}

public class UnauthorizedException : AppException {
    public override int StatusCode => 401;
    public override string Code => "UNAUTHORIZED";
    public UnauthorizedException(string msg = "Unauthorized") : base(msg) {}
}

Global Exception Middleware

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

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

    public async Task InvokeAsync(HttpContext ctx) {
        try { await _next(ctx); }
        catch (Exception ex) { await HandleAsync(ctx, ex); }
    }

    private async Task HandleAsync(HttpContext ctx, Exception ex) {
        _logger.LogError(ex, "Unhandled: {Path}", ctx.Request.Path);

        var (status, code, message) = ex switch {
            AppException ae => (ae.StatusCode, ae.Code, ae.Message),
            ArgumentException => (400, "BAD_REQUEST", ex.Message),
            UnauthorizedAccessException => (401, "UNAUTHORIZED", "Access denied"),
            KeyNotFoundException => (404, "NOT_FOUND", ex.Message),
            _ => (500, "INTERNAL_ERROR", _env.IsDevelopment() ? ex.Message : "Server error")
        };

        ctx.Response.StatusCode = status;
        ctx.Response.ContentType = "application/problem+json";

        var problem = new {
            type = $"https://httpstatuses.com/{status}",
            title = code,
            status,
            detail = message,
            traceId = ctx.TraceIdentifier,
            errors = (ex is ValidationException ve) ? ve.Errors : null,
            stackTrace = _env.IsDevelopment() ? ex.StackTrace : null
        };

        await ctx.Response.WriteAsJsonAsync(problem);
    }
}

Register

// Program.cs — সবার আগে
app.UseMiddleware<ExceptionMiddleware>();

.NET 8+ — IExceptionHandler ⭐

public class GlobalExceptionHandler : IExceptionHandler {
    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx, Exception ex, CancellationToken ct)
    {
        var (status, msg) = ex switch {
            NotFoundException => (404, ex.Message),
            ValidationException => (422, ex.Message),
            _ => (500, "Server error")
        };

        ctx.Response.StatusCode = status;
        await ctx.Response.WriteAsJsonAsync(new { error = msg }, ct);
        return true;
    }
}

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();

ProblemDetails (RFC 7807)

{
  "type": "https://example.com/probs/out-of-credit",
  "title": "Out of credit",
  "status": 403,
  "detail": "Your balance is 30",
  "instance": "/account/12345",
  "traceId": "00-abc-123",
  "errors": { ... }
}

Use in Controller

[HttpGet("{id}")]
public async Task<ActionResult<Product>> Get(int id) {
    var p = await _db.Products.FindAsync(id);
    if (p == null) throw new NotFoundException("Product", id);
    return p;
}