Exception Handling Strategy in Clean Architecture

Exception Handling Strategy in Clean Architecture

6 min read··

clean-architecturedotnetsoftware-architecture

In Clean Architecture, each layer gets one clear job in the error handling story. The domain throws exceptions for invariant violations, the application returns typed Result failures for expected business errors, and a global exception handler maps everything unexpected to Problem Details.

Every .NET codebase eventually develops an exception handling strategy, usually by accident. One handler throws, another returns null, and the API layer plays catch-and-guess with whatever bubbles up. Here is the full strategy, from domain invariants to Problem Details responses.

The Problem

Exceptions fly around your application - validation errors, not-found scenarios, business rule violations, infrastructure failures. Without a clear strategy, exception handling becomes inconsistent:

  • Some handlers throw exceptions, others return nulls
  • The API layer catches NullReferenceException and guesses what went wrong
  • Business errors are mixed with infrastructure failures
  • Error responses are inconsistent across endpoints

A Layered Exception Strategy

Each layer in Clean Architecture handles errors differently:

  • Domain: throw domain exceptions for invariant violations
  • Application: return Result<T> for expected business errors
  • Infrastructure: let infrastructure exceptions propagate
  • Presentation: map results and exceptions to HTTP responses

Domain Layer: Guard Invariants

The Domain layer throws exceptions when invariants are violated - things that should never happen if the system is working correctly:

public class Order
{
    private Order(Guid customerId, List<LineItem> lineItems)
    {
        if (customerId == Guid.Empty)
            throw new DomainException("Customer ID cannot be empty.");

        if (lineItems.Count == 0)
            throw new DomainException("Order must have at least one line item.");

        Id = Guid.NewGuid();
        CustomerId = customerId;
        LineItems = lineItems;
        Status = OrderStatus.Draft;
    }

    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public List<LineItem> LineItems { get; private set; }
    public OrderStatus Status { get; private set; }
    public string? CancellationReason { get; private set; }

    public static Order Create(Guid customerId, List<LineItem> lineItems) =>
        new(customerId, lineItems);

    public void Cancel(string reason)
    {
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Cannot cancel a shipped order.");

        Status = OrderStatus.Cancelled;
        CancellationReason = reason;
    }
}

Domain exceptions signal bugs or invalid state transitions. They're not for expected scenarios like "customer not found."

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

Application Layer: Result Pattern

The Application layer uses the Result pattern for expected business failures:

public sealed class PlaceOrderCommandHandler
    : ICommandHandler<PlaceOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IUnitOfWork _unitOfWork;

    public PlaceOrderCommandHandler(
        IOrderRepository orderRepository,
        ICustomerRepository customerRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(
        PlaceOrderCommand command, CancellationToken ct)
    {
        var customer = await _customerRepository.GetByIdAsync(
            command.CustomerId, ct);

        if (customer is null)
        {
            return Result.Failure<Guid>(
                CustomerErrors.NotFound(command.CustomerId));
        }

        if (!customer.IsActive)
        {
            return Result.Failure<Guid>(
                CustomerErrors.Inactive(command.CustomerId));
        }

        var order = Order.Create(command.CustomerId, command.Items);

        _orderRepository.Add(order);
        await _unitOfWork.SaveChangesAsync(ct);

        return order.Id;
    }
}

Define typed errors:

public static class CustomerErrors
{
    public static Error NotFound(Guid id) => new(
        "Customer.NotFound",
        $"Customer with ID '{id}' was not found.",
        ErrorType.NotFound);

    public static Error Inactive(Guid id) => new(
        "Customer.Inactive",
        $"Customer with ID '{id}' is inactive.",
        ErrorType.Conflict);
}

public record Error(string Code, string Description, ErrorType Type);

public enum ErrorType
{
    Validation,
    NotFound,
    Conflict,
    Forbidden,
    Failure
}

The handler never throws for expected scenarios. Customer not found is not exceptional - it's an expected outcome.

Validation: Before the Handler

Use a validation pipeline behavior to reject invalid requests before the handler runs:

public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
    where TResponse : Result
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var failures = _validators
            .Select(v => v.Validate(request))
            .SelectMany(result => result.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count != 0)
        {
            return CreateValidationResult<TResponse>(failures);
        }

        return await next();
    }
}

Validation errors are returned as Result.Failure with ErrorType.Validation - not thrown as exceptions.

The CreateValidationResult helper needs a small amount of reflection to construct either Result or Result<T> depending on the request type. I walk through the complete implementation in CQRS validation with MediatR pipeline and FluentValidation.

Presentation Layer: Map to HTTP

The API layer maps Result<T> to HTTP responses:

app.MapPost("/api/orders", async (
    PlaceOrderRequest request,
    ISender sender,
    CancellationToken ct) =>
{
    var command = new PlaceOrderCommand(request.CustomerId, request.Items);
    var result = await sender.Send(command, ct);

    return result.IsSuccess
        ? Results.Created($"/api/orders/{result.Value}", result.Value)
        : result.ToProblemDetails();
});
public static IResult ToProblemDetails(this Result result)
{
    if (result.IsSuccess)
    {
        throw new InvalidOperationException(
            "Can't convert a success result to a problem response.");
    }

    return Results.Problem(
        statusCode: result.Error.Type switch
        {
            ErrorType.Validation => StatusCodes.Status400BadRequest,
            ErrorType.NotFound => StatusCodes.Status404NotFound,
            ErrorType.Conflict => StatusCodes.Status409Conflict,
            ErrorType.Forbidden => StatusCodes.Status403Forbidden,
            _ => StatusCodes.Status500InternalServerError
        },
        title: result.Error.Code,
        detail: result.Error.Description);
}

Global Exception Handler: Safety Net

For unexpected exceptions (bugs, infrastructure failures), use global error handling:

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var exception = context.Features
            .Get<IExceptionHandlerFeature>()?.Error;

        var (statusCode, title) = exception switch
        {
            DomainException => (400, "Domain Rule Violation"),
            _ => (500, "Internal Server Error")
        };

        context.Response.StatusCode = statusCode;
        context.Response.ContentType = "application/problem+json";

        await context.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Detail = statusCode == 500
                ? "An unexpected error occurred."
                : exception?.Message
        });
    });
});

Or with .NET 8's IExceptionHandler:

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);

        httpContext.Response.StatusCode = exception switch
        {
            DomainException => StatusCodes.Status400BadRequest,
            _ => StatusCodes.Status500InternalServerError
        };

        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = httpContext.Response.StatusCode,
            Title = "An error occurred",
            Detail = httpContext.Response.StatusCode == 500
                ? "An unexpected error occurred."
                : exception.Message
        }, ct);

        return true;
    }
}

Never expose internal exception details in production. Log the full exception, return a generic message to the client.

Either way, keep the response shape consistent with Problem Details so expected failures and unexpected errors look the same to API consumers.

When to Throw and When to Return a Result

When an error occurs, how do you decide what to do?

Decision flow routing each error kind: invalid input, not found, and business rules become Result failures mapped to Problem Details, while domain invariant violations and infrastructure failures flow to the global exception handler
  1. Invalid input → Validation behavior returns Result.Failure(ValidationError)
  2. Entity not found → Handler returns Result.Failure(NotFoundError)
  3. Business rule violation (expected) → Handler returns Result.Failure(ConflictError)
  4. Domain invariant violated → Domain throws DomainException → global handler catches
  5. Infrastructure failure → Exception propagates → global handler catches and logs

Rules of thumb:

  • Expected failuresResult<T>
  • Programming errors → Exceptions
  • Infrastructure issues → Exceptions

Summary

A consistent exception handling strategy in Clean Architecture:

  1. Domain layer throws exceptions for invariant violations
  2. Application layer returns Result<T> for expected business errors
  3. Validation happens before the handler via pipeline behaviors
  4. The presentation layer maps results to HTTP responses
  5. A global exception handler catches unexpected failures

Stop mixing exceptions and return values randomly. Pick a clear strategy and apply it consistently.

Thanks for reading, and stay awesome!


Frequently Asked Questions

Should I use exceptions or the Result pattern in Clean Architecture?

Use both, for different things. Return Result for expected business failures like not-found or conflict scenarios. Throw exceptions for domain invariant violations, programming errors, and infrastructure failures that a global handler catches.

Where should exceptions be caught in Clean Architecture?

At the outermost layer. A global exception handler in the Presentation layer catches anything unexpected, logs it, and returns a Problem Details response. Handlers in the Application layer should not have try-catch blocks for control flow.

Is "entity not found" an exception or a result?

A result. A missing entity is an expected outcome of user input, not an exceptional condition. Return a failure result with a NotFound error type and let the API layer map it to a 404 response.

What is a domain exception?

A custom exception type thrown by the domain layer when an invariant is violated, such as constructing an order with no line items. It signals a bug or invalid state transition rather than an expected business scenario.

How do you return consistent error responses from an ASP.NET Core API?

Map every failure to the Problem Details format (RFC 9457). Translate Result error types to status codes in one extension method, and have the global exception handler produce Problem Details too, so expected and unexpected errors share the same shape.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.