⚡ Backend Intermediate ⏱️ 15 min

Error Handling and Resilience Patterns in .NET

Implement robust error handling with Result patterns, global exception handling, and Polly resilience policies in .NET applications.

By Victor Robin

Introduction

Robust systems must anticipate and handle failures gracefully. In distributed architectures, errors are inevitable—services timeout, databases lock, and networks partition. Relying solely on try-catch blocks often leads to “exception-driven logic” which is hard to reason about and expensive at runtime. This guide explores implementing resilient error handling in .NET using the Result pattern for domain logic and Polly policies for infrastructure resilience, ensuring system stability under pressure.

Architecture Overview

The following diagram illustrates the multi-layered error handling strategy, from the domain layer up to the API and external integrations.

flowchart TB
    Client["📱 Client"] --> API["🛡️ API Endpoint<br/>(Global Exception Handler)"]
    
    subgraph App["Application Layer"]
        Service["⚙️ Service Layer<br/>(Result Pattern)"]
        Retry["🔄 Polly Retry Policy"]
        Breaker["⚡ Circuit Breaker"]
    end
    
    subgraph Ext["External Systems"]
        DB[("🗄️ Database")]
        Remote["☁️ External API"]
    end

    API --> Service
    Service -->|Success/Failure| API
    
    Service --> DB
    Service --> Retry
    Retry --> Breaker
    Breaker --> Remote

    classDef primary fill:#7c3aed,color:#fff
    classDef secondary fill:#06b6d4,color:#fff
    classDef db fill:#f43f5e,color:#fff
    classDef warning fill:#fbbf24,color:#000

    class Service,API primary
    class Retry,Breaker secondary
    class Client,Remote warning
    class DB db

Implementation

Result Pattern

Result Type Implementation

// Core/Common/Result.cs
public sealed class Result<T>
{
    public T? Value { get; }
    public Error? Error { get; }
    // ...existing code...
    public bool IsSuccess => Error is null;
    public bool IsFailure => !IsSuccess;

    private Result(T value)
    {
        Value = value;
        Error = null;
    }

    private Result(Error error)
    {
        Value = default;
        Error = error;
    }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(Error error) => new(error);

    public TResult Match<TResult>(
        Func<T, TResult> onSuccess,
        Func<Error, TResult> onFailure) =>
        IsSuccess ? onSuccess(Value!) : onFailure(Error!);

    public async Task<TResult> MatchAsync<TResult>(
        Func<T, Task<TResult>> onSuccess,
        Func<Error, Task<TResult>> onFailure) =>
        IsSuccess ? await onSuccess(Value!) : await onFailure(Error!);
}

public static class Result
{
    public static Result<T> Success<T>(T value) => Result<T>.Success(value);
    public static Result<T> Failure<T>(Error error) => Result<T>.Failure(error);
}

Error Type

// Core/Common/Error.cs
public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
{
    public static Error NotFound(string resource, string id) =>
        new($"{resource}.NotFound", $"{resource} with ID '{id}' was not found", ErrorType.NotFound);

    public static Error Validation(string code, string message) =>
        new(code, message, ErrorType.Validation);

    public static Error Conflict(string code, string message) =>
        new(code, message, ErrorType.Conflict);

    public static Error Forbidden(string code, string message) =>
        new(code, message, ErrorType.Forbidden);

    public static Error Internal(string message) =>
        new("Internal.Error", message, ErrorType.Internal);
}

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

Using Result in Services

// Application/Services/DocumentService.cs
public sealed class DocumentService : IDocumentService
{
    private readonly IDocumentRepository _repository;

    public async Task<Result<Document>> GetByIdAsync(
        DocumentId id,
        BlueRobinId userId,
        CancellationToken ct = default)
    {
        var document = await _repository.GetByIdAsync(id, ct);
        
        if (document is null)
        {
            return Result.Failure<Document>(
                Error.NotFound("Document", id.Value.ToString()));
        }

        if (document.OwnerId != userId)
        {
            return Result.Failure<Document>(
                Error.Forbidden("Document.Access", "You don't have access to this document"));
        }

        return Result.Success(document);
    }

    public async Task<Result<DocumentId>> CreateAsync(
        CreateDocumentCommand command,
        CancellationToken ct = default)
    {
        // Validation
        if (string.IsNullOrWhiteSpace(command.Name))
        {
            return Result.Failure<DocumentId>(
                Error.Validation("Document.Name.Required", "Document name is required"));
        }

        var document = Document.Create(command.Name, command.OwnerId);
        await _repository.AddAsync(document, ct);
        
        return Result.Success(document.Id);
    }
}

FastEndpoints Error Handling

Endpoint with Result Pattern

// Api/Endpoints/Documents/GetDocumentEndpoint.cs
public sealed class GetDocumentEndpoint : Endpoint<GetDocumentRequest, DocumentResponse>
{
    private readonly IDocumentService _service;

    public override void Configure()
    {
        Get("/api/documents/{id}");
    }

    public override async Task HandleAsync(GetDocumentRequest req, CancellationToken ct)
    {
        var userId = BlueRobinId.From(User.FindFirst("bluerobin_id")!.Value);
        var result = await _service.GetByIdAsync(req.Id, userId, ct);

        await result.Match(
            onSuccess: async document =>
            {
                await SendAsync(document.ToResponse(), cancellation: ct);
            },
            onFailure: async error =>
            {
                await SendErrorAsync(error, ct);
            });
    }

    private Task SendErrorAsync(Error error, CancellationToken ct)
    {
        return error.Type switch
        {
            ErrorType.NotFound => SendNotFoundAsync(ct),
            ErrorType.Validation => SendAsync(new ErrorResponse(error), 400, ct),
            ErrorType.Forbidden => SendForbiddenAsync(ct),
            ErrorType.Conflict => SendAsync(new ErrorResponse(error), 409, ct),
            _ => SendAsync(new ErrorResponse(error), 500, ct)
        };
    }
}

Global Exception Handler

Exception Handling Middleware

// Api/Middleware/GlobalExceptionHandler.cs
public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

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

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

        var (statusCode, errorResponse) = exception switch
        {
            ValidationException ex => (400, new ProblemDetails
            {
                Status = 400,
                Title = "Validation Error",
                Detail = ex.Message,
                Extensions = { ["errors"] = ex.Errors }
            }),
            
            NotFoundException ex => (404, new ProblemDetails
            {
                Status = 404,
                Title = "Not Found",
                Detail = ex.Message
            }),
            
            UnauthorizedAccessException => (403, new ProblemDetails
            {
                Status = 403,
                Title = "Forbidden",
                Detail = "You don't have permission to access this resource"
            }),
            
            OperationCanceledException => (499, new ProblemDetails
            {
                Status = 499,
                Title = "Request Cancelled",
                Detail = "The request was cancelled"
            }),
            
            _ => (500, new ProblemDetails
            {
                Status = 500,
                Title = "Internal Server Error",
                Detail = "An unexpected error occurred"
            })
        };

        context.Response.StatusCode = statusCode;
        context.Response.ContentType = "application/problem+json";
        
        await context.Response.WriteAsJsonAsync(errorResponse, ct);
        
        return true;
    }
}

Polly Resilience

Resilience Pipeline Configuration

// Infrastructure/Extensions/ResilienceExtensions.cs
public static class ResilienceExtensions
{
    public static IServiceCollection AddResiliencePolicies(this IServiceCollection services)
    {
        services.AddResiliencePipeline("default", builder =>
        {
            builder
                .AddRetry(new RetryStrategyOptions
                {
                    MaxRetryAttempts = 3,
                    Delay = TimeSpan.FromMilliseconds(500),
                    BackoffType = DelayBackoffType.Exponential,
                    UseJitter = true,
                    ShouldHandle = new PredicateBuilder()
                        .Handle<HttpRequestException>()
                        .Handle<TimeoutException>()
                })
                .AddCircuitBreaker(new CircuitBreakerStrategyOptions
                {
                    FailureRatio = 0.5,
                    SamplingDuration = TimeSpan.FromSeconds(30),
                    MinimumThroughput = 10,
                    BreakDuration = TimeSpan.FromSeconds(30),
                    ShouldHandle = new PredicateBuilder()
                        .Handle<HttpRequestException>()
                        .Handle<TimeoutException>()
                })
                .AddTimeout(TimeSpan.FromSeconds(30));
        });

        return services;
    }
}

Using Resilience in HttpClient

// Program.cs
builder.Services.AddHttpClient<IOcrService, DoclingOcrService>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Docling:Endpoint"]!);
    client.Timeout = TimeSpan.FromMinutes(5);
})
.AddStandardResilienceHandler(options =>
{
    options.Retry.MaxRetryAttempts = 3;
    options.Retry.BackoffType = DelayBackoffType.Exponential;
    options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
    options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(5);
});

Manual Resilience Execution

// Application/Services/EmbeddingService.cs
public sealed class EmbeddingService : IEmbeddingService
{
    private readonly ResiliencePipeline _pipeline;
    private readonly IEmbeddingClient _client;
    private readonly ILogger<EmbeddingService> _logger;

    public async Task<float[]> GenerateAsync(
        string text,
        CancellationToken ct = default)
    {
        return await _pipeline.ExecuteAsync(async token =>
        {
            try
            {
                return await _client.EmbedAsync(text, token);
            }
            catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
            {
                _logger.LogWarning("Embedding service unavailable, retrying...");
                throw;
            }
        }, ct);
    }
}

Domain Exceptions

Custom Exception Types

// Core/Exceptions/DomainException.cs
public abstract class DomainException : Exception
{
    public string Code { get; }

    protected DomainException(string code, string message) : base(message)
    {
        Code = code;
    }
}

public sealed class NotFoundException : DomainException
{
    public NotFoundException(string resource, object id)
        : base($"{resource}.NotFound", $"{resource} with ID '{id}' was not found")
    {
    }
}

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

    public ValidationException(IDictionary<string, string[]> errors)
        : base("Validation.Failed", "One or more validation errors occurred")
    {
        Errors = errors;
    }
}

public sealed class ConflictException : DomainException
{
    public ConflictException(string resource, string reason)
        : base($"{resource}.Conflict", reason)
    {
    }
}

Error Response Models

Standard Error Response

// Api/Models/ErrorResponse.cs
public sealed record ErrorResponse(
    string Code,
    string Message,
    IDictionary<string, string[]>? Errors = null)
{
    public ErrorResponse(Error error) : this(error.Code, error.Message)
    {
    }
}

public sealed record ValidationErrorResponse(
    string Code,
    string Message,
    IDictionary<string, string[]> Errors)
    : ErrorResponse(Code, Message, Errors)
{
    public ValidationErrorResponse(IDictionary<string, string[]> errors)
        : this("Validation.Failed", "One or more validation errors occurred", errors)
    {
    }
}

Conclusion

By combining the Result pattern for domain logic with Polly’s resilience strategies, we create a defense-in-depth approach to error handling. This limits the blast radius of failures and provides a predictable experience for both developers and end-users. The Result pattern eliminates the ambiguity of exceptions for flow control, while Polly ensures that transient faults in external systems don’t cascade through the application.

[Polly Documentation] — Polly Project