This commit is contained in:
Ho Ngoc Hai
2026-05-23 18:37:02 +07:00
parent f15d91ee29
commit 76d75c753b
3993 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,889 @@
# Error Handling Patterns - Detailed Reference
Detailed code examples for error handling and resiliency patterns in ASP.NET Core.
## Table of Contents
1. [Exception Middleware](#exception-middleware)
2. [Domain Exceptions](#domain-exceptions)
3. [Validation with FluentValidation](#validation-with-fluentvalidation)
4. [Result Pattern](#result-pattern)
5. [Polly Resiliency](#polly-resiliency)
6. [Health Checks](#health-checks)
7. [Problem Details](#problem-details)
---
## Exception Middleware
### Complete Exception Handler
```csharp
/// <summary>
/// EN: Global exception handler middleware with detailed error mapping.
/// VI: Middleware xử lý exception toàn cục với ánh xạ lỗi chi tiết.
/// </summary>
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
private readonly IHostEnvironment _environment;
public ExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlerMiddleware> logger,
IHostEnvironment environment)
{
_next = next;
_logger = logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
// EN: Log with appropriate level based on exception type
// VI: Log với level phù hợp dựa trên loại exception
LogException(ex, traceId);
var response = CreateErrorResponse(ex, traceId);
context.Response.StatusCode = response.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(response.Body);
}
private void LogException(Exception ex, string traceId)
{
var message = "EN: Error occurred / VI: Đã xảy ra lỗi TraceId: {TraceId}";
switch (ex)
{
case ValidationException:
case NotFoundException:
_logger.LogWarning(ex, message, traceId);
break;
case DomainException:
_logger.LogWarning(ex, message, traceId);
break;
default:
_logger.LogError(ex, message, traceId);
break;
}
}
private (int StatusCode, object Body) CreateErrorResponse(Exception ex, string traceId)
{
return ex switch
{
ValidationException ve => (400, new ErrorResponse
{
Success = false,
Error = ve.Message,
ErrorCode = "VALIDATION_ERROR",
Errors = ve.Errors,
TraceId = traceId
}),
UnauthorizedAccessException => (401, new ErrorResponse
{
Success = false,
Error = "Authentication required",
ErrorCode = "UNAUTHORIZED",
TraceId = traceId
}),
ForbiddenException fe => (403, new ErrorResponse
{
Success = false,
Error = fe.Message,
ErrorCode = "FORBIDDEN",
TraceId = traceId
}),
NotFoundException nf => (404, new ErrorResponse
{
Success = false,
Error = nf.Message,
ErrorCode = "NOT_FOUND",
TraceId = traceId
}),
ConflictException ce => (409, new ErrorResponse
{
Success = false,
Error = ce.Message,
ErrorCode = "CONFLICT",
TraceId = traceId
}),
DomainException de => (422, new ErrorResponse
{
Success = false,
Error = de.Message,
ErrorCode = de.ErrorCode,
TraceId = traceId
}),
_ => (500, new ErrorResponse
{
Success = false,
Error = _environment.IsDevelopment()
? ex.Message
: "An unexpected error occurred",
ErrorCode = "INTERNAL_ERROR",
TraceId = traceId,
StackTrace = _environment.IsDevelopment() ? ex.StackTrace : null
})
};
}
}
/// <summary>
/// EN: Standardized error response.
/// VI: Response lỗi chuẩn hóa.
/// </summary>
public class ErrorResponse
{
public bool Success { get; set; }
public string Error { get; set; } = string.Empty;
public string ErrorCode { get; set; } = string.Empty;
public string? TraceId { get; set; }
public IReadOnlyDictionary<string, string[]>? Errors { get; set; }
public string? StackTrace { get; set; }
}
```
---
## Domain Exceptions
### Complete Exception Hierarchy
```csharp
/// <summary>
/// EN: Base domain exception with error code.
/// VI: Domain exception cơ sở với error code.
/// </summary>
public abstract class DomainException : Exception
{
public string ErrorCode { get; }
protected DomainException(string message, string errorCode)
: base(message)
{
ErrorCode = errorCode;
}
protected DomainException(string message, string errorCode, Exception innerException)
: base(message, innerException)
{
ErrorCode = errorCode;
}
}
/// <summary>
/// EN: Validation exception with field-level errors.
/// VI: Exception validation với lỗi từng field.
/// </summary>
public class ValidationException : DomainException
{
public IReadOnlyDictionary<string, string[]> Errors { get; }
public ValidationException(string message)
: base(message, "VALIDATION_ERROR")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(string field, string error)
: base($"Validation failed for {field}", "VALIDATION_ERROR")
{
Errors = new Dictionary<string, string[]>
{
{ field, new[] { error } }
};
}
public ValidationException(IDictionary<string, string[]> errors)
: base("One or more validation errors occurred", "VALIDATION_ERROR")
{
Errors = new Dictionary<string, string[]>(errors);
}
public ValidationException(IEnumerable<FluentValidation.Results.ValidationFailure> failures)
: base("One or more validation errors occurred", "VALIDATION_ERROR")
{
Errors = failures
.GroupBy(f => f.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(f => f.ErrorMessage).ToArray());
}
}
/// <summary>
/// EN: Resource not found exception.
/// VI: Exception không tìm thấy resource.
/// </summary>
public class NotFoundException : DomainException
{
public string ResourceType { get; }
public object? ResourceId { get; }
public NotFoundException(string resourceType, object resourceId)
: base($"{resourceType} with ID '{resourceId}' was not found", "NOT_FOUND")
{
ResourceType = resourceType;
ResourceId = resourceId;
}
public NotFoundException(string message)
: base(message, "NOT_FOUND")
{
ResourceType = "Resource";
}
}
/// <summary>
/// EN: Conflict exception for duplicate resources.
/// VI: Exception xung đột cho resource trùng lặp.
/// </summary>
public class ConflictException : DomainException
{
public ConflictException(string message)
: base(message, "CONFLICT")
{ }
public ConflictException(string resourceType, string identifier)
: base($"{resourceType} with identifier '{identifier}' already exists", "CONFLICT")
{ }
}
/// <summary>
/// EN: Forbidden exception for permission errors.
/// VI: Exception forbidden cho lỗi phân quyền.
/// </summary>
public class ForbiddenException : DomainException
{
public ForbiddenException(string message = "You do not have permission to perform this action")
: base(message, "FORBIDDEN")
{ }
}
/// <summary>
/// EN: Business rule exception.
/// VI: Exception quy tắc nghiệp vụ.
/// </summary>
public class BusinessRuleException : DomainException
{
public BusinessRuleException(string message, string errorCode = "BUSINESS_RULE_VIOLATION")
: base(message, errorCode)
{ }
}
```
---
## Validation with FluentValidation
### Validation Behavior for MediatR
```csharp
/// <summary>
/// EN: MediatR pipeline behavior for FluentValidation.
/// VI: MediatR pipeline behavior cho FluentValidation.
/// </summary>
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
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 cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
// EN: Registration in Program.cs / VI: Đăng ký trong Program.cs
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
```
### Sample Validators
```csharp
/// <summary>
/// EN: Validator for CreateOrderCommand.
/// VI: Validator cho CreateOrderCommand.
/// </summary>
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty().WithMessage("User ID is required");
RuleFor(x => x.ShippingAddress)
.NotNull().WithMessage("Shipping address is required")
.SetValidator(new AddressValidator()!);
RuleFor(x => x.Items)
.NotEmpty().WithMessage("At least one item is required");
RuleForEach(x => x.Items)
.SetValidator(new OrderItemValidator());
}
}
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(x => x.Street)
.NotEmpty().WithMessage("Street is required")
.MaximumLength(200);
RuleFor(x => x.City)
.NotEmpty().WithMessage("City is required")
.MaximumLength(100);
RuleFor(x => x.PostalCode)
.NotEmpty().WithMessage("Postal code is required")
.Matches(@"^\d{5}(-\d{4})?$").WithMessage("Invalid postal code format");
}
}
public class OrderItemValidator : AbstractValidator<OrderItemDto>
{
public OrderItemValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty().WithMessage("Product ID is required");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be positive");
RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative");
}
}
```
---
## Result Pattern
### Generic Result Type
```csharp
/// <summary>
/// EN: Result type for operations that can fail.
/// VI: Result type cho operations có thể thất bại.
/// </summary>
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public string? Error { get; }
public string? ErrorCode { get; }
protected Result(bool isSuccess, string? error, string? errorCode)
{
IsSuccess = isSuccess;
Error = error;
ErrorCode = errorCode;
}
public static Result Success() => new(true, null, null);
public static Result Failure(string error, string errorCode = "ERROR")
=> new(false, error, errorCode);
public static Result<T> Success<T>(T value) => new(value, true, null, null);
public static Result<T> Failure<T>(string error, string errorCode = "ERROR")
=> new(default!, false, error, errorCode);
}
/// <summary>
/// EN: Result type with value.
/// VI: Result type có giá trị.
/// </summary>
public class Result<T> : Result
{
public T Value { get; }
internal Result(T value, bool isSuccess, string? error, string? errorCode)
: base(isSuccess, error, errorCode)
{
Value = value;
}
public static implicit operator Result<T>(T value) => Success(value);
}
/// <summary>
/// EN: Result extensions for common patterns.
/// VI: Extensions cho các mẫu phổ biến.
/// </summary>
public static class ResultExtensions
{
public static Result<TOut> Map<TIn, TOut>(
this Result<TIn> result,
Func<TIn, TOut> mapper)
{
return result.IsSuccess
? Result.Success(mapper(result.Value))
: Result.Failure<TOut>(result.Error!, result.ErrorCode!);
}
public static async Task<Result<TOut>> MapAsync<TIn, TOut>(
this Task<Result<TIn>> resultTask,
Func<TIn, TOut> mapper)
{
var result = await resultTask;
return result.Map(mapper);
}
public static Result<T> Ensure<T>(
this Result<T> result,
Func<T, bool> predicate,
string error,
string errorCode = "ERROR")
{
if (result.IsFailure)
return result;
return predicate(result.Value)
? result
: Result.Failure<T>(error, errorCode);
}
}
```
### Using Result Pattern
```csharp
/// <summary>
/// EN: Command handler returning Result instead of throwing.
/// VI: Command handler trả về Result thay vì throw.
/// </summary>
public class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, Result<OrderResult>>
{
private readonly IOrderRepository _orderRepository;
private readonly IUserService _userService;
public async Task<Result<OrderResult>> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
// EN: Validate user exists
// VI: Xác thực user tồn tại
var userExists = await _userService.ExistsAsync(request.UserId, ct);
if (!userExists)
return Result.Failure<OrderResult>("User not found", "USER_NOT_FOUND");
// EN: Validate items
// VI: Xác thực items
if (!request.Items.Any())
return Result.Failure<OrderResult>("At least one item required", "EMPTY_ORDER");
// EN: Create order
// VI: Tạo order
var order = new Order(request.UserId, request.ShippingAddress);
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
await _orderRepository.AddAsync(order, ct);
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
return Result.Success(new OrderResult(order.Id));
}
}
// EN: Controller using Result pattern / VI: Controller dùng Result pattern
[HttpPost]
public async Task<IActionResult> CreateOrder(
CreateOrderRequest request,
CancellationToken ct)
{
var command = request.ToCommand(GetUserId());
var result = await _mediator.Send(command, ct);
if (result.IsFailure)
{
return result.ErrorCode switch
{
"USER_NOT_FOUND" => NotFound(ApiResponse.Fail(result.Error!, result.ErrorCode)),
"EMPTY_ORDER" => BadRequest(ApiResponse.Fail(result.Error!, result.ErrorCode)),
_ => StatusCode(500, ApiResponse.Fail(result.Error!, result.ErrorCode))
};
}
return CreatedAtAction(
nameof(GetOrder),
new { orderId = result.Value.OrderId },
ApiResponse.Ok(result.Value));
}
```
---
## Polly Resiliency
### Comprehensive Resilience Configuration
```csharp
/// <summary>
/// EN: Configure resilient HTTP client with Polly.
/// VI: Cấu hình HTTP client có khả năng phục hồi với Polly.
/// </summary>
// EN: Using Microsoft.Extensions.Http.Resilience (Polly v8 integration)
// VI: Sử dụng Microsoft.Extensions.Http.Resilience (tích hợp Polly v8)
builder.Services.AddHttpClient<IExternalPaymentService, ExternalPaymentService>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:Payment:BaseUrl"]!);
})
.AddResilienceHandler("payment-service", builder =>
{
// EN: Retry policy with exponential backoff
// VI: Retry policy với thời gian chờ tăng dần
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(500),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // EN: Add randomness / VI: Thêm ngẫu nhiên
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => (int)r.StatusCode >= 500)
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>(),
OnRetry = args =>
{
Console.WriteLine(
$"EN: Retry {args.AttemptNumber} after {args.RetryDelay} / " +
$"VI: Thử lại lần {args.AttemptNumber} sau {args.RetryDelay}");
return ValueTask.CompletedTask;
}
});
// EN: Circuit breaker
// VI: Circuit breaker
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5, // EN: 50% failures / VI: 50% thất bại
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30),
OnOpened = args =>
{
Console.WriteLine("EN: Circuit opened / VI: Circuit đã mở");
return ValueTask.CompletedTask;
},
OnClosed = args =>
{
Console.WriteLine("EN: Circuit closed / VI: Circuit đã đóng");
return ValueTask.CompletedTask;
}
});
// EN: Timeout per request
// VI: Timeout cho mỗi request
builder.AddTimeout(TimeSpan.FromSeconds(10));
});
```
### Resilient Database Operations
```csharp
/// <summary>
/// EN: Configure EF Core with retry logic.
/// VI: Cấu hình EF Core với logic retry.
/// </summary>
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(
builder.Configuration.GetConnectionString("DefaultConnection"),
npgsqlOptions =>
{
// EN: Enable retry on transient failures
// VI: Bật retry cho lỗi tạm thời
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorCodesToAdd: null);
npgsqlOptions.CommandTimeout(30);
});
});
```
---
## Health Checks
### Comprehensive Health Check Setup
```csharp
/// <summary>
/// EN: Configure comprehensive health checks.
/// VI: Cấu hình health checks toàn diện.
/// </summary>
// EN: Add health checks / VI: Thêm health checks
builder.Services.AddHealthChecks()
// EN: Database / VI: Database
.AddNpgSql(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgresql",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "db", "critical" })
// EN: Redis cache / VI: Redis cache
.AddRedis(
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: new[] { "cache" })
// EN: External services / VI: Services bên ngoài
.AddUrlGroup(
new Uri(builder.Configuration["Services:Payment:HealthUrl"]!),
name: "payment-service",
failureStatus: HealthStatus.Degraded,
tags: new[] { "external" })
// EN: Custom health check / VI: Health check tùy chỉnh
.AddCheck<StorageHealthCheck>("storage", tags: new[] { "storage", "critical" });
// EN: Configure endpoints / VI: Cấu hình endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
// EN: Liveness - just check if app is running
// VI: Liveness - chỉ kiểm tra app có đang chạy không
Predicate = _ => false,
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"status\":\"Healthy\"}");
}
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
// EN: Readiness - check critical dependencies
// VI: Readiness - kiểm tra các dependency quan trọng
Predicate = check => check.Tags.Contains("critical"),
ResponseWriter = WriteHealthCheckResponse
});
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = WriteHealthCheckResponse
});
static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description,
exception = e.Value.Exception?.Message
})
};
await context.Response.WriteAsJsonAsync(result);
}
```
### Custom Health Check Implementation
```csharp
/// <summary>
/// EN: Custom health check for storage service.
/// VI: Health check tùy chỉnh cho storage service.
/// </summary>
public class StorageHealthCheck : IHealthCheck
{
private readonly IMinioClient _minioClient;
private readonly ILogger<StorageHealthCheck> _logger;
public StorageHealthCheck(
IMinioClient minioClient,
ILogger<StorageHealthCheck> logger)
{
_minioClient = minioClient;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// EN: Check if bucket exists and is accessible
// VI: Kiểm tra bucket tồn tại và có thể truy cập
var bucketExists = await _minioClient.BucketExistsAsync(
new BucketExistsArgs().WithBucket("uploads"),
cancellationToken);
if (!bucketExists)
{
return HealthCheckResult.Degraded(
"Storage bucket not found",
data: new Dictionary<string, object>
{
{ "bucket", "uploads" }
});
}
return HealthCheckResult.Healthy(
"Storage service is healthy",
data: new Dictionary<string, object>
{
{ "bucket", "uploads" },
{ "status", "available" }
});
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Storage health check failed / VI: Health check storage thất bại");
return HealthCheckResult.Unhealthy(
"Storage service is unavailable",
exception: ex);
}
}
}
```
---
## Problem Details
### RFC 7807 Problem Details
```csharp
/// <summary>
/// EN: Configure Problem Details for standardized errors.
/// VI: Cấu hình Problem Details cho lỗi chuẩn hóa.
/// </summary>
// EN: Add Problem Details / VI: Thêm Problem Details
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
};
});
// EN: Use exception handler with Problem Details
// VI: Sử dụng exception handler với Problem Details
app.UseExceptionHandler(appBuilder =>
{
appBuilder.Run(async context =>
{
var exceptionHandler = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandler?.Error;
if (exception != null)
{
var problemDetails = exception switch
{
ValidationException ve => new ProblemDetails
{
Status = 400,
Title = "Validation Error",
Detail = ve.Message,
Extensions = { ["errors"] = ve.Errors }
},
NotFoundException nf => new ProblemDetails
{
Status = 404,
Title = "Not Found",
Detail = nf.Message
},
_ => new ProblemDetails
{
Status = 500,
Title = "Internal Server Error",
Detail = "An unexpected error occurred"
}
};
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
context.Response.StatusCode = problemDetails.Status ?? 500;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
}
});
});
```
---
## Resources / Tài Nguyên
- [Microsoft: Handle Errors in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/web-api/handle-errors)
- [Polly Documentation](https://github.com/App-vNext/Polly)
- [FluentValidation](https://docs.fluentvalidation.net/)
- [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807)
- [Health Checks in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks)