# Exception Handling Strategy in Clean Architecture

> Expected failures are not exceptional, so stop throwing them. A clear strategy gives each layer one job: the domain throws on invariant violations, the application returns typed Results, and a single global handler maps everything else to Problem Details. Here is how the pieces fit together in a .NET Clean Architecture.

Published: 2026-08-11. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/exception-handling-clean-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](https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design) 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](https://milanjovanovic.tech/blog/domain-layer-clean-architecture) throws exceptions when invariants are violated - things that should never happen if the system is working correctly:

```csharp
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."

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

## Application Layer: Result Pattern

The [Application layer](https://milanjovanovic.tech/blog/application-layer-clean-architecture) uses the [Result pattern](https://milanjovanovic.tech/blog/functional-error-handling-in-dotnet-with-the-result-pattern) for expected business failures:

```csharp
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:

```csharp
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:

```csharp
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**](https://milanjovanovic.tech/blog/cqrs-validation-with-mediatr-pipeline-and-fluentvalidation).

## Presentation Layer: Map to HTTP

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

```csharp
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();
});
```

```csharp
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](https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-from-middleware-to-modern-handlers):

```csharp
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`:

```csharp
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**](https://milanjovanovic.tech/blog/problem-details-for-aspnetcore-apis) 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](https://milanjovanovic.tech/blogs/articles/exception-handling-clean-architecture/error-decision-flow.png)

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 failures** → `Result<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.
