Validation and DTOs
A well-designed API rejects invalid input before it reaches the database. In this presentation you will build the request and response DTOs, add validation with data annotations, explore FluentValidation as an alternative, and handle the 409 Conflict scenario for referential integrity.
Request DTO Design
The CreateAddressRequest DTO represents the data a client sends to create a new address. It intentionally excludes the Id field because the server generates the ID.
csharp1namespace ParcelTracking.Application.DTOs;23public class CreateAddressRequest4{5 public string Street1 { get; set; } = string.Empty;6 public string? Street2 { get; set; }7 public string City { get; set; } = string.Empty;8 public string State { get; set; } = string.Empty;9 public string PostalCode { get; set; } = string.Empty;10 public string CountryCode { get; set; } = string.Empty;11 public bool IsResidential { get; set; }12 public string ContactName { get; set; } = string.Empty;13 public string? CompanyName { get; set; }14 public string Phone { get; set; } = string.Empty;15 public string? Email { get; set; }16}
For updates, you can reuse the same shape. Some teams create a separate UpdateAddressRequest to allow independent evolution:
csharp1public class UpdateAddressRequest2{3 public string Street1 { get; set; } = string.Empty;4 public string? Street2 { get; set; }5 public string City { get; set; } = string.Empty;6 public string State { get; set; } = string.Empty;7 public string PostalCode { get; set; } = string.Empty;8 public string CountryCode { get; set; } = string.Empty;9 public bool IsResidential { get; set; }10 public string ContactName { get; set; } = string.Empty;11 public string? CompanyName { get; set; }12 public string Phone { get; set; } = string.Empty;13 public string? Email { get; set; }14}
Response DTO Design
The response DTO includes the Id so the client can reference the resource:
csharp1public class AddressResponse2{3 public Guid Id { get; set; }4 public string Street1 { get; set; } = string.Empty;5 public string? Street2 { get; set; }6 public string City { get; set; } = string.Empty;7 public string State { get; set; } = string.Empty;8 public string PostalCode { get; set; } = string.Empty;9 public string CountryCode { get; set; } = string.Empty;10 public bool IsResidential { get; set; }11 public string ContactName { get; set; } = string.Empty;12 public string? CompanyName { get; set; }13 public string Phone { get; set; } = string.Empty;14 public string? Email { get; set; }15}
The response DTO can evolve independently of the entity. For example, you might add a computed FullAddress property later without changing the database schema.
Validation with Data Annotations
Data annotations are attributes from System.ComponentModel.DataAnnotations that declare validation rules directly on properties. When the [ApiController] attribute is present, ASP.NET Core validates the model automatically before the action method executes and returns a 400 response if validation fails.
csharp1using System.ComponentModel.DataAnnotations;23public class CreateAddressRequest4{5 [Required]6 [StringLength(200)]7 public string Street1 { get; set; } = string.Empty;89 [StringLength(200)]10 public string? Street2 { get; set; }1112 [Required]13 [StringLength(100)]14 public string City { get; set; } = string.Empty;1516 [Required]17 [StringLength(100)]18 public string State { get; set; } = string.Empty;1920 [Required]21 [StringLength(20)]22 public string PostalCode { get; set; } = string.Empty;2324 [Required]25 [StringLength(2, MinimumLength = 2)]26 [RegularExpression(@"^[A-Z]{2}$", ErrorMessage = "CountryCode must be a 2-letter uppercase ISO code.")]27 public string CountryCode { get; set; } = string.Empty;2829 public bool IsResidential { get; set; }3031 [Required]32 [StringLength(150)]33 public string ContactName { get; set; } = string.Empty;3435 [StringLength(150)]36 public string? CompanyName { get; set; }3738 [Required]39 [Phone]40 public string Phone { get; set; } = string.Empty;4142 [EmailAddress]43 public string? Email { get; set; }44}
What Each Annotation Does
| Annotation | Effect |
|---|---|
[Required] | Rejects null or empty strings; returns 400 if missing |
[StringLength(200)] | Limits maximum length; prevents oversized input |
[StringLength(2, MinimumLength = 2)] | Enforces exact length of 2 characters |
[RegularExpression] | Validates format with a regex pattern |
[Phone] | Validates phone number format |
[EmailAddress] | Validates email format |
Automatic Validation Behavior
With [ApiController], you do not need to check ModelState.IsValid manually. If validation fails, ASP.NET Core short-circuits the request and returns a 400 Bad Request with a ValidationProblemDetails body:
json1{2 "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",3 "title": "One or more validation errors occurred.",4 "status": 400,5 "errors": {6 "Street1": ["The Street1 field is required."],7 "CountryCode": ["CountryCode must be a 2-letter uppercase ISO code."]8 }9}
This response follows the RFC 7807 Problem Details standard, which gives clients a predictable error format to parse.
Custom Validation: Country Code Allowlist
The [RegularExpression] attribute ensures the country code is two uppercase letters, but it does not verify the code is an actual ISO 3166-1 country. You can create a custom validation attribute for this:
csharp1using System.ComponentModel.DataAnnotations;23public class ValidCountryCodeAttribute : ValidationAttribute4{5 private static readonly HashSet<string> ValidCodes = new(StringComparer.Ordinal)6 {7 "US", "CA", "MX", "GB", "DE", "FR", "ES", "IT", "NL", "BE",8 "AU", "NZ", "JP", "KR", "CN", "IN", "BR", "AR", "CL", "CO",9 "SE", "NO", "DK", "FI", "PL", "CZ", "AT", "CH", "IE", "PT"10 // Add more as needed or load from a configuration source11 };1213 protected override ValidationResult? IsValid(object? value, ValidationContext context)14 {15 if (value is string code && ValidCodes.Contains(code))16 return ValidationResult.Success;1718 return new ValidationResult($"'{value}' is not a supported country code.");19 }20}
Apply it alongside the existing annotations:
csharp1[Required]2[StringLength(2, MinimumLength = 2)]3[ValidCountryCode]4public string CountryCode { get; set; } = string.Empty;
Now the country code is validated for both format and actual existence in the supported list.
Postal Code Validation Per Country
Different countries have different postal code formats. US uses 5 or 9 digits, Canada uses letter-digit-letter-digit-letter-digit, UK has its own format. A custom attribute can validate based on the country code:
csharp1public class ValidPostalCodeAttribute : ValidationAttribute2{3 protected override ValidationResult? IsValid(object? value, ValidationContext context)4 {5 var instance = context.ObjectInstance;6 var countryCodeProp = instance.GetType().GetProperty("CountryCode");7 var countryCode = countryCodeProp?.GetValue(instance) as string;89 if (value is not string postalCode || string.IsNullOrWhiteSpace(postalCode))10 return new ValidationResult("Postal code is required.");1112 var pattern = countryCode switch13 {14 "US" => @"^\d{5}(-\d{4})?$",15 "CA" => @"^[A-Z]\d[A-Z]\s?\d[A-Z]\d$",16 "GB" => @"^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$",17 "DE" => @"^\d{5}$",18 "FR" => @"^\d{5}$",19 "AU" => @"^\d{4}$",20 "JP" => @"^\d{3}-?\d{4}$",21 "BR" => @"^\d{5}-?\d{3}$",22 _ => @"^.{2,20}$"23 };2425 if (!System.Text.RegularExpressions.Regex.IsMatch(postalCode, pattern))26 return new ValidationResult(27 $"Postal code '{postalCode}' is not valid for country '{countryCode}'.");2829 return ValidationResult.Success;30 }31}
Apply it to the PostalCode property:
csharp1[Required]2[ValidPostalCode]3public string PostalCode { get; set; } = string.Empty;
This attribute reads the CountryCode from the same object and picks the correct regex pattern. The fallback pattern accepts any 2-20 character string for countries without a specific rule.
FluentValidation Alternative
Data annotations work well for simple rules, but as validation logic grows, the DTO becomes cluttered. FluentValidation separates validation rules into dedicated validator classes:
bash1dotnet add src/ParcelTracking.Application package FluentValidation.AspNetCore
csharp1using FluentValidation;23public class CreateAddressRequestValidator : AbstractValidator<CreateAddressRequest>4{5 private static readonly HashSet<string> ValidCountryCodes = new(StringComparer.Ordinal)6 {7 "US", "CA", "MX", "GB", "DE", "FR", "ES", "IT", "NL", "BE",8 "AU", "NZ", "JP", "KR", "CN", "IN", "BR", "AR", "CL", "CO"9 };1011 public CreateAddressRequestValidator()12 {13 RuleFor(x => x.Street1)14 .NotEmpty()15 .MaximumLength(200);1617 RuleFor(x => x.Street2)18 .MaximumLength(200);1920 RuleFor(x => x.City)21 .NotEmpty()22 .MaximumLength(100);2324 RuleFor(x => x.State)25 .NotEmpty()26 .MaximumLength(100);2728 RuleFor(x => x.CountryCode)29 .NotEmpty()30 .Length(2)31 .Must(code => ValidCountryCodes.Contains(code))32 .WithMessage("'{PropertyValue}' is not a supported country code.");3334 RuleFor(x => x.PostalCode)35 .NotEmpty()36 .MaximumLength(20);3738 RuleFor(x => x.PostalCode)39 .Matches(@"^\d{5}(-\d{4})?$")40 .When(x => x.CountryCode == "US")41 .WithMessage("US postal code must be 5 digits or 5+4 format.");4243 RuleFor(x => x.PostalCode)44 .Matches(@"^[A-Z]\d[A-Z]\s?\d[A-Z]\d$")45 .When(x => x.CountryCode == "CA")46 .WithMessage("Canadian postal code must follow A1A 1A1 format.");4748 RuleFor(x => x.ContactName)49 .NotEmpty()50 .MaximumLength(150);5152 RuleFor(x => x.CompanyName)53 .MaximumLength(150);5455 RuleFor(x => x.Phone)56 .NotEmpty();5758 RuleFor(x => x.Email)59 .EmailAddress()60 .When(x => !string.IsNullOrEmpty(x.Email));61 }62}
Registering FluentValidation
Register all validators from the assembly in Program.cs:
csharp1using FluentValidation;2using FluentValidation.AspNetCore;34builder.Services.AddFluentValidationAutoValidation();5builder.Services.AddValidatorsFromAssemblyContaining<CreateAddressRequestValidator>();
With this setup, FluentValidation integrates with the ASP.NET Core model validation pipeline. Invalid requests still return 400 with the same ValidationProblemDetails format, so clients do not need to change.
Data Annotations vs FluentValidation
| Aspect | Data Annotations | FluentValidation |
|---|---|---|
| Location | On the DTO properties | In a separate validator class |
| Cross-field rules | Awkward (need custom attributes) | Natural with .When() |
| Conditional logic | Limited | Full C# expressions |
| Testability | Hard to unit test | Easy to test independently |
| Dependencies | Built-in, no extra packages | Requires NuGet package |
For this project, either approach works. Data annotations are simpler for small DTOs. FluentValidation shines when rules involve multiple properties, like validating postal code format based on the country code.
Handling 409 Conflict for Referential Integrity
When a client tries to delete an address that is referenced by parcels, the API must reject the request. The correct HTTP status code is 409 Conflict, which indicates the request conflicts with the current state of the resource.
Checking for References
Before deleting, query the parcels table to see if any parcel uses this address as a shipper or recipient:
csharp1[HttpDelete("{id}")]2public async Task<IActionResult> Delete(Guid id)3{4 var address = await _db.Addresses.FindAsync(id);56 if (address is null)7 return NotFound();89 var parcelCount = await _db.Parcels10 .CountAsync(p => p.ShipperAddressId == id || p.RecipientAddressId == id);1112 if (parcelCount > 0)13 {14 return Conflict(new15 {16 message = $"Cannot delete address. It is referenced by {parcelCount} parcel(s)."17 });18 }1920 _db.Addresses.Remove(address);21 await _db.SaveChangesAsync();2223 return NoContent();24}
Using CountAsync instead of AnyAsync lets you tell the client exactly how many parcels reference this address, which helps them understand the scope of the conflict.
Returning a Structured Error
The anonymous object new { message = "..." } works but is inconsistent with the ValidationProblemDetails format used by validation errors. For a more consistent API, use ProblemDetails:
csharp1if (parcelCount > 0)2{3 return Conflict(new ProblemDetails4 {5 Status = 409,6 Title = "Conflict",7 Detail = $"Cannot delete address. It is referenced by {parcelCount} parcel(s)."8 });9}
This returns a response that follows the same RFC 7807 structure:
json1{2 "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",3 "title": "Conflict",4 "status": 409,5 "detail": "Cannot delete address. It is referenced by 3 parcel(s)."6}
Why Not Cascade Delete?
You could configure EF Core to cascade delete parcels when an address is deleted. But this is dangerous in a parcel tracking system. Deleting a warehouse address should not wipe out the shipping history of every parcel that originated from that warehouse. Explicit rejection with 409 is the safe choice.
Combining Validation Layers
A robust API validates at multiple levels. Here is how the layers fit together:
1HTTP Request2 │3 ▼4┌─────────────────────────┐5│ Model Binding │ Deserialize JSON to DTO6├─────────────────────────┤7│ Data Annotations / │ Validate format, required fields8│ FluentValidation │ → 400 Bad Request9├─────────────────────────┤10│ Controller Logic │ Check business rules11│ │ → 404 Not Found, 409 Conflict12├─────────────────────────┤13│ Database Constraints │ Unique indexes, foreign keys14│ │ → 500 if reached (bug in validation)15└─────────────────────────┘
Validation at the DTO level catches the majority of bad input. Controller logic handles business rules that require database queries. Database constraints act as a final safety net. If a constraint violation occurs, it means the application-level validation has a gap that should be fixed.
Summary
Request and response DTOs decouple the API contract from the database model. Data annotations provide simple, declarative validation. FluentValidation offers more power for cross-field rules. The 409 Conflict response protects referential integrity without risking data loss from cascade deletes. Together, these patterns produce an API that rejects bad input early and gives clients actionable error messages.