Learn ASP.NET Core validation and error handling: Data Annotations, FluentValidation, global exception middleware, and RFC 7807 Problem Details.
Validation and error handling are critical for robust APIs. ASP.NET Core integrates Data Annotations for declarative validation, FluentValidation for complex rules, and RFC 7807 Problem Details for standardized error responses.
Use built-in validation attributes on DTOs:
1using System.ComponentModel.DataAnnotations;
2
3// DTO with validation attributes
4public class CreateUserDto
5{
6 [Required(ErrorMessage = "First name is required")]
7 [StringLength(50, MinimumLength = 2, ErrorMessage = "First name must be 2-50 characters")]
8 public string FirstName { get; set; } = "";
9
10 [Required(ErrorMessage = "Last name is required")]
11 [StringLength(50, MinimumLength = 2)]
12 public string LastName { get; set; } = "";
13
14 [Required(ErrorMessage = "Email is required")]
15 [EmailAddress(ErrorMessage = "Invalid email format")]
16 public string Email { get; set; } = "";
17
18 [Required]
19 [MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
20 [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$",
21 ErrorMessage = "Password must contain uppercase, lowercase, digit, and special character")]
22 public string Password { get; set; } = "";
23
24 [Compare("Password", ErrorMessage = "Passwords do not match")]
25 public string ConfirmPassword { get; set; } = "";
26
27 [Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
28 public int Age { get; set; }
29
30 [Phone(ErrorMessage = "Invalid phone number")]
31 public string? Phone { get; set; }
32
33 [Url(ErrorMessage = "Invalid URL format")]
34 public string? Website { get; set; }
35
36 [CreditCard(ErrorMessage = "Invalid credit card number")]
37 public string? CreditCard { get; set; }
38}
39
40// Custom validation attribute
41public class FutureDateAttribute : ValidationAttribute
42{
43 public override bool IsValid(object? value)
44 {
45 if (value is DateTime date)
46 {
47 return date > DateTime.Now;
48 }
49 return false;
50 }
51
52 public override string FormatErrorMessage(string name)
53 {
54 return $"{name} must be a future date";
55 }
56}
57
58public class BookingDto
59{
60 [Required]
61 [FutureDate(ErrorMessage = "Booking date must be in the future")]
62 public DateTime BookingDate { get; set; }
63}
64
65// Controller with automatic validation (ApiController attribute)
66[ApiController]
67[Route("api/users")]
68public class UsersController : ControllerBase
69{
70 [HttpPost]
71 public IActionResult CreateUser(CreateUserDto dto)
72 {
73 // Validation happens automatically
74 // If invalid, returns 400 with validation errors
75
76 // If we reach here, dto is valid
77 return Ok(new { message = "User created", email = dto.Email });
78 }
79
80 // Manual validation check
81 [HttpPost("manual")]
82 public IActionResult CreateUserManual(CreateUserDto dto)
83 {
84 if (!ModelState.IsValid)
85 {
86 var errors = ModelState
87 .Where(x => x.Value?.Errors.Count > 0)
88 .ToDictionary(
89 x => x.Key,
90 x => x.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
91 );
92 return BadRequest(new { errors });
93 }
94
95 return Ok();
96 }
97}FluentValidation provides composable, testable validators:
1using FluentValidation;
2
3// DTO
4public class CreateProductDto
5{
6 public string Name { get; set; } = "";
7 public string Sku { get; set; } = "";
8 public decimal Price { get; set; }
9 public int Stock { get; set; }
10 public string Category { get; set; } = "";
11 public List<string> Tags { get; set; } = new();
12}
13
14// Validator class
15public class CreateProductValidator : AbstractValidator<CreateProductDto>
16{
17 private readonly IProductRepository _productRepo;
18
19 public CreateProductValidator(IProductRepository productRepo)
20 {
21 _productRepo = productRepo;
22
23 RuleFor(x => x.Name)
24 .NotEmpty().WithMessage("Product name is required")
25 .Length(3, 100).WithMessage("Name must be 3-100 characters")
26 .Must(name => !name.Contains("test", StringComparison.OrdinalIgnoreCase))
27 .WithMessage("Product name cannot contain 'test'");
28
29 RuleFor(x => x.Sku)
30 .NotEmpty()
31 .Matches(@"^[A-Z]{3}-\\d{4}$").WithMessage("SKU must be in format XXX-0000")
32 .MustAsync(BeUniqueSku).WithMessage("SKU already exists");
33
34 RuleFor(x => x.Price)
35 .GreaterThan(0).WithMessage("Price must be positive")
36 .LessThanOrEqualTo(999999.99m).WithMessage("Price exceeds maximum");
37
38 RuleFor(x => x.Stock)
39 .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative");
40
41 RuleFor(x => x.Category)
42 .NotEmpty()
43 .Must(BeValidCategory).WithMessage("Invalid category");
44
45 RuleFor(x => x.Tags)
46 .Must(tags => tags == null || tags.Count <= 10)
47 .WithMessage("Maximum 10 tags allowed");
48
49 RuleForEach(x => x.Tags)
50 .Length(2, 20).WithMessage("Each tag must be 2-20 characters");
51
52 // Conditional validation
53 When(x => x.Category == "Electronics", () =>
54 {
55 RuleFor(x => x.Price)
56 .GreaterThanOrEqualTo(10m)
57 .WithMessage("Electronics must cost at least $10");
58 });
59 }
60
61 private async Task<bool> BeUniqueSku(string sku, CancellationToken ct)
62 {
63 return !await _productRepo.SkuExistsAsync(sku, ct);
64 }
65
66 private bool BeValidCategory(string category)
67 {
68 var validCategories = new[] { "Electronics", "Clothing", "Food", "Books" };
69 return validCategories.Contains(category);
70 }
71}
72
73// Register in DI
74builder.Services.AddScoped<IValidator<CreateProductDto>, CreateProductValidator>();
75builder.Services.AddFluentValidationAutoValidation();
76
77// Use in controller
78[HttpPost]
79public async Task<IActionResult> CreateProduct(
80 CreateProductDto dto,
81 IValidator<CreateProductDto> validator)
82{
83 var result = await validator.ValidateAsync(dto);
84
85 if (!result.IsValid)
86 {
87 return BadRequest(new
88 {
89 errors = result.Errors.Select(e => new
90 {
91 field = e.PropertyName,
92 message = e.ErrorMessage
93 })
94 });
95 }
96
97 // Create product...
98 return Ok();
99}Catch all unhandled exceptions and return structured responses:
1// Custom exceptions
2public class NotFoundException : Exception
3{
4 public string ResourceType { get; }
5 public object ResourceId { get; }
6
7 public NotFoundException(string resourceType, object resourceId)
8 : base($"{resourceType} with ID '{resourceId}' was not found")
9 {
10 ResourceType = resourceType;
11 ResourceId = resourceId;
12 }
13}
14
15public class ValidationException : Exception
16{
17 public Dictionary<string, string[]> Errors { get; }
18
19 public ValidationException(Dictionary<string, string[]> errors)
20 : base("One or more validation errors occurred")
21 {
22 Errors = errors;
23 }
24}
25
26public class ConflictException : Exception
27{
28 public ConflictException(string message) : base(message) { }
29}
30
31// Global exception handler middleware
32public class ExceptionHandlerMiddleware
33{
34 private readonly RequestDelegate _next;
35 private readonly ILogger<ExceptionHandlerMiddleware> _logger;
36
37 public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
38 {
39 _next = next;
40 _logger = logger;
41 }
42
43 public async Task InvokeAsync(HttpContext context)
44 {
45 try
46 {
47 await _next(context);
48 }
49 catch (Exception ex)
50 {
51 await HandleExceptionAsync(context, ex);
52 }
53 }
54
55 private async Task HandleExceptionAsync(HttpContext context, Exception ex)
56 {
57 var (statusCode, response) = ex switch
58 {
59 NotFoundException notFound => (404, new
60 {
61 type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
62 title = "Resource not found",
63 status = 404,
64 detail = notFound.Message,
65 resourceType = notFound.ResourceType,
66 resourceId = notFound.ResourceId
67 } as object),
68
69 ValidationException validation => (400, new
70 {
71 type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
72 title = "Validation failed",
73 status = 400,
74 errors = validation.Errors
75 }),
76
77 ConflictException conflict => (409, new
78 {
79 type = "https://tools.ietf.org/html/rfc7231#section-6.5.8",
80 title = "Conflict",
81 status = 409,
82 detail = conflict.Message
83 }),
84
85 UnauthorizedAccessException => (401, new
86 {
87 type = "https://tools.ietf.org/html/rfc7235#section-3.1",
88 title = "Unauthorized",
89 status = 401
90 }),
91
92 _ => (500, new
93 {
94 type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
95 title = "Internal server error",
96 status = 500,
97 traceId = context.TraceIdentifier
98 } as object)
99 };
100
101 if (statusCode == 500)
102 {
103 _logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
104 }
105
106 context.Response.StatusCode = statusCode;
107 context.Response.ContentType = "application/problem+json";
108 await context.Response.WriteAsJsonAsync(response);
109 }
110}
111
112// Register middleware
113app.UseMiddleware<ExceptionHandlerMiddleware>();Return machine-readable error responses conforming to RFC 7807:
1// Configure Problem Details in Program.cs
2builder.Services.AddProblemDetails(options =>
3{
4 options.CustomizeProblemDetails = context =>
5 {
6 context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
7 context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
8
9 // Add request info in development
10 if (context.HttpContext.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment())
11 {
12 context.ProblemDetails.Extensions["requestPath"] = context.HttpContext.Request.Path;
13 context.ProblemDetails.Extensions["method"] = context.HttpContext.Request.Method;
14 }
15 };
16});
17
18app.UseExceptionHandler();
19app.UseStatusCodePages();
20
21// Custom ProblemDetails factory
22public class CustomProblemDetailsFactory : ProblemDetailsFactory
23{
24 public override ProblemDetails CreateProblemDetails(
25 HttpContext httpContext,
26 int? statusCode = null,
27 string? title = null,
28 string? type = null,
29 string? detail = null,
30 string? instance = null)
31 {
32 var problemDetails = new ProblemDetails
33 {
34 Status = statusCode ?? 500,
35 Title = title ?? GetDefaultTitle(statusCode ?? 500),
36 Type = type ?? GetDefaultType(statusCode ?? 500),
37 Detail = detail,
38 Instance = instance ?? httpContext.Request.Path
39 };
40
41 problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
42 return problemDetails;
43 }
44
45 public override ValidationProblemDetails CreateValidationProblemDetails(
46 HttpContext httpContext,
47 ModelStateDictionary modelStateDictionary,
48 int? statusCode = null,
49 string? title = null,
50 string? type = null,
51 string? detail = null,
52 string? instance = null)
53 {
54 var problemDetails = new ValidationProblemDetails(modelStateDictionary)
55 {
56 Status = statusCode ?? 400,
57 Title = title ?? "Validation failed",
58 Type = type ?? "https://tools.ietf.org/html/rfc7231#section-6.5.1",
59 Detail = detail ?? "One or more validation errors occurred",
60 Instance = instance ?? httpContext.Request.Path
61 };
62
63 problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
64 return problemDetails;
65 }
66
67 private string GetDefaultTitle(int statusCode) => statusCode switch
68 {
69 400 => "Bad Request",
70 401 => "Unauthorized",
71 403 => "Forbidden",
72 404 => "Not Found",
73 409 => "Conflict",
74 500 => "Internal Server Error",
75 _ => "Error"
76 };
77
78 private string GetDefaultType(int statusCode) =>
79 $"https://httpstatuses.com/{statusCode}";
80}
81
82// Example response format
83/*
84{
85 "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
86 "title": "Not Found",
87 "status": 404,
88 "detail": "Product with ID '999' was not found",
89 "instance": "/api/products/999",
90 "traceId": "0HN2ABC123",
91 "timestamp": "2024-01-15T10:30:00Z"
92}
93*/Your progress is saved in this browser, on this device only.