Validation in ConnectSoft Microservice Template¶
Purpose & Overview¶
Validation is a fundamental cross-cutting concern in the ConnectSoft Microservice Template, serving as the first boundary of correctness for all inputs entering the system. It ensures that data is well-formed, semantically valid, and contract-compliant before it reaches business logic, persistence, or side effects.
Key Principles¶
The validation system follows these core principles:
- Fail Fast: Invalid inputs are rejected immediately, preventing invalid state propagation
- Layered Enforcement: Validation occurs at multiple layers (API, Application, Domain, Configuration)
- Framework-Agnostic: Supports both FluentValidation and DataAnnotations based on context
- Configurable: Fail-fast behavior controlled via configuration
- Testable: Validators are first-class components with dedicated unit tests
- Reusable: Validators can be used across REST APIs, messaging handlers, background jobs, and use cases
Validation Philosophy
Validation is the first line of defense in system integrity. It ensures that invalid data never reaches domain logic, preventing corruption, maintaining consistency, and providing clear feedback to clients. Validation is embedded throughout the template—from configuration options to domain commands—ensuring reliability and safety by default.
Architecture Overview¶
Validation Layers¶
Validation occurs at multiple layers in the microservice architecture:
API Layer (REST/gRPC)
├── Model Binding Validation (DataAnnotations)
└── FluentValidation (Request DTOs)
↓
Application Layer (Processors/Use Cases)
├── Input Validation (FluentValidation)
└── Pre-execution Checks
↓
Domain Layer (Aggregates/Entities)
├── Invariant Enforcement
└── Business Rule Validation
↓
Configuration Layer
├── Options Validation (DataAnnotations + [OptionsValidator])
└── Startup Validation
Validation Technologies¶
| Technology | Primary Use | Characteristics |
|---|---|---|
| FluentValidation | Complex input validation, DTOs, commands | Rule chaining, conditional logic, async support, testable |
| DataAnnotations | Simple structure validation, options, service models | Attribute-based, lightweight, built-in ASP.NET support |
| Source Generators | Options validation ([OptionsValidator]) |
Compile-time generation, zero runtime overhead |
Integration Points¶
| Context | Validation Method | Integration |
|---|---|---|
| REST APIs | DataAnnotations + FluentValidation | ASP.NET Core model binding |
| gRPC Services | FluentValidation + manual validation | gRPC interceptors |
| Domain Commands | FluentValidation | Use-case-level validation |
| NServiceBus Messages | DataAnnotations | UseDataAnnotationsValidation() |
| Options/Configuration | DataAnnotations + [OptionsValidator] |
IOptions<T> with ValidateOnStart() |
| Background Jobs | FluentValidation | Manual validation in handlers |
FluentValidation¶
Purpose¶
FluentValidation is the primary validation framework for complex, business-oriented validation rules. It provides a fluent, testable DSL for defining validation logic with support for conditional rules, async validation, and rule composition.
Configuration¶
FluentValidation is registered in the application startup:
The AddFluentValidation() extension method:
// FluentValidationExtensions.cs
internal static IServiceCollection AddFluentValidation(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Scan and register all validators from the assembly
services.AddValidatorsFromAssemblyContaining<CreateMicroserviceAggregateRootInputValidator>();
return services;
}
Key Features:
- Assembly Scanning: Automatically discovers all IValidator<T> implementations
- Manual Validation: Validators are used manually in gRPC interceptors and domain model validation
- Singleton Registration: Validators are registered as singletons for performance
- DI Integration: Validators are injectable via IValidator<T>
Note: Automatic validation via AddFluentValidationAutoValidation() is no longer recommended by FluentValidation. The template uses manual validation instead, which provides better control and aligns with FluentValidation's current recommendations.
Creating Validators¶
Validators extend AbstractValidator<T> and define rules using a fluent API:
// CreateMicroserviceAggregateRootInputValidator.cs
namespace ConnectSoft.MicroserviceTemplate.DomainModel.Impl.Validators
{
using ConnectSoft.MicroserviceTemplate.DomainModel;
using FluentValidation;
public sealed class CreateMicroserviceAggregateRootInputValidator
: AbstractValidator<CreateMicroserviceAggregateRootInput>
{
public CreateMicroserviceAggregateRootInputValidator()
{
RuleFor(x => x.ObjectId)
.NotEmpty()
.WithMessage("ObjectId is required and cannot be empty.");
RuleFor(x => x.ObjectId)
.Must(id => id != Guid.Empty)
.WithMessage("ObjectId cannot be Guid.Empty.");
}
}
}
Validator Structure:
- Extends AbstractValidator<TInput>
- Defines rules in constructor using RuleFor(x => x.Property)
- Supports chained rules (.NotEmpty().MaximumLength(100))
- Custom error messages via .WithMessage()
- Custom predicates via .Must()
Common Validation Rules¶
Basic Rules¶
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200)
.WithMessage("Name must not exceed 200 characters.");
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.WithMessage("Email must be a valid email address.");
RuleFor(x => x.Age)
.GreaterThan(0)
.LessThanOrEqualTo(120)
.WithMessage("Age must be between 1 and 120.");
RuleFor(x => x.Price)
.GreaterThan(0)
.ScalePrecision(2, 18, true)
.WithMessage("Price must be greater than 0 with up to 2 decimal places.");
Conditional Rules¶
RuleFor(x => x.PhoneNumber)
.NotEmpty()
.When(x => x.RequiresContact)
.WithMessage("Phone number is required when contact is required.");
RuleFor(x => x.DiscountCode)
.NotEmpty()
.When(x => x.ApplyDiscount)
.WithMessage("Discount code is required when applying discount.");
RuleFor(x => x.EndDate)
.GreaterThan(x => x.StartDate)
.When(x => x.StartDate.HasValue)
.WithMessage("End date must be after start date.");
Custom Validation Logic¶
RuleFor(x => x.Username)
.Must(BeUniqueUsername)
.WithMessage("Username is already taken.");
RuleFor(x => x.Token)
.Must(BeValidJwt)
.WithMessage("Invalid or expired JWT token.");
private bool BeUniqueUsername(string username)
{
// Check against repository or service
return !_userRepository.Exists(username);
}
private bool BeValidJwt(string token)
{
return _jwtValidator.IsValid(token);
}
Async Validation¶
RuleFor(x => x.Email)
.MustAsync(async (email, cancellation) =>
!await _userRepository.ExistsAsync(email, cancellation))
.WithMessage("Email is already registered.");
Cross-Property Validation¶
RuleFor(x => x)
.Must(x => x.Password == x.ConfirmPassword)
.WithMessage("Password and confirmation password must match.");
RuleFor(x => x)
.Must(x => x.StartDate <= x.EndDate)
.WithMessage("Start date must be before or equal to end date.");
Rule Sets¶
Rule sets allow context-specific validation:
RuleSet("UpdateOnly", () =>
{
RuleFor(x => x.UpdatedBy)
.NotEmpty()
.WithMessage("UpdatedBy is required for updates.");
RuleFor(x => x.ChangeSummary)
.MaximumLength(500)
.WithMessage("Change summary cannot exceed 500 characters.");
});
RuleSet("CreateOnly", () =>
{
RuleFor(x => x.CreatedBy)
.NotEmpty()
.WithMessage("CreatedBy is required for creation.");
});
Usage:
var result = await validator.ValidateAsync(
input,
options => options.IncludeRuleSets("UpdateOnly"));
Using Validators in Application Services¶
Validators are injected via dependency injection:
// DefaultMicroserviceAggregateRootsProcessor.cs
public class DefaultMicroserviceAggregateRootsProcessor : IMicroserviceAggregateRootsProcessor
{
private readonly IValidator<CreateMicroserviceAggregateRootInput> createValidator;
private readonly IMicroserviceAggregateRootsRepository repository;
private readonly IUnitOfWork unitOfWork;
public DefaultMicroserviceAggregateRootsProcessor(
IValidator<CreateMicroserviceAggregateRootInput> createValidator,
IMicroserviceAggregateRootsRepository repository,
IUnitOfWork unitOfWork)
{
this.createValidator = createValidator;
this.repository = repository;
this.unitOfWork = unitOfWork;
}
public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
CreateMicroserviceAggregateRootInput input,
CancellationToken token = default)
{
// Validate input before processing
await this.createValidator.ValidateAndThrowAsync(input, token);
// Validation passed - proceed with business logic
var entity = new MicroserviceAggregateRootEntity
{
ObjectId = input.ObjectId
};
this.unitOfWork.ExecuteTransactional(() =>
{
this.repository.Insert(entity);
});
return entity;
}
}
Validation Methods:
| Method | Behavior |
|---|---|
ValidateAsync(input) |
Returns ValidationResult with IsValid and Errors |
ValidateAndThrowAsync(input) |
Throws ValidationException if validation fails |
ValidateAsync(input, options) |
Validates with options (rule sets, etc.) |
ValidationResult¶
The ValidationResult provides detailed information about validation failures:
var result = await validator.ValidateAsync(input);
if (!result.IsValid)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"Property: {error.PropertyName}");
Console.WriteLine($"Error: {error.ErrorMessage}");
Console.WriteLine($"Attempted Value: {error.AttemptedValue}");
}
}
Properties:
- IsValid: Boolean indicating if validation passed
- Errors: Collection of ValidationFailure objects
- RuleSetsExecuted: Rule sets that were executed
ValidationOptions Configuration¶
Purpose¶
ValidationOptions provides runtime control over FluentValidation behavior, enabling per-environment customization of fail-fast strategies and validation strictness.
Configuration¶
Validation options are configured in appsettings.json:
ValidationOptions Class¶
// ValidationOptions.cs
namespace ConnectSoft.MicroserviceTemplate.Options
{
public sealed class ValidationOptions
{
public const string ValidationOptionsSectionName = "Validation";
public bool EnableClassLevelFailFast { get; init; } = true;
public bool EnableRuleLevelFailFast { get; init; } = true;
}
}
Options:
| Option | Description | Default |
|---|---|---|
EnableClassLevelFailFast |
Stops validation when the first validator fails | true |
EnableRuleLevelFailFast |
Stops processing after the first failed rule in a validator | true |
Fail-Fast Behavior¶
| Mode | Behavior | Use Case |
|---|---|---|
| Class-Level Fail-Fast | Stops when first validator fails | Production - best performance |
| Rule-Level Fail-Fast | Stops when first rule fails in a validator | Production - early termination |
| Both Enabled | Fastest validation, least verbose errors | Production |
| Both Disabled | All rules execute, comprehensive error list | QA/Staging - full diagnostics |
Options Registration¶
Validation options are registered with validation:
// OptionsExtensions.cs
services.AddOptions<ValidationOptions>()
.BindConfiguration(ValidationOptions.ValidationOptionsSectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
Using ValidationOptions¶
// Apply options to FluentValidation configuration
var options = serviceProvider.GetRequiredService<IOptions<ValidationOptions>>().Value;
ValidatorOptions.Global.DefaultClassLevelCascadeMode =
options.EnableClassLevelFailFast
? CascadeMode.Stop
: CascadeMode.Continue;
ValidatorOptions.Global.DefaultRuleLevelCascadeMode =
options.EnableRuleLevelFailFast
? CascadeMode.Stop
: CascadeMode.Continue;
DataAnnotations¶
Purpose¶
DataAnnotations provide lightweight, attribute-based validation for simple structural checks. They are ideal for options classes, service models, and message contracts where complex validation logic is not required.
Supported Attributes¶
| Attribute | Purpose | Example |
|---|---|---|
[Required] |
Ensures property is not null/empty | [Required] public string Name { get; set; } |
[Range(min, max)] |
Validates numeric range | [Range(0, 100)] public int Percentage { get; set; } |
[StringLength(max)] |
Validates string length | [StringLength(200)] public string Description { get; set; } |
[EmailAddress] |
Validates email format | [EmailAddress] public string Email { get; set; } |
[Url] |
Validates URL format | [Url] public string Website { get; set; } |
[RegularExpression(pattern)] |
Validates regex pattern | [RegularExpression(@"^\d{3}-\d{2}-\d{4}$")] |
[ValidateObjectMembers] |
Validates nested objects | [ValidateObjectMembers] public NestedOptions Nested { get; set; } |
Usage in Options Classes¶
// MicroserviceOptions.cs
namespace ConnectSoft.MicroserviceTemplate.Options
{
using System.ComponentModel.DataAnnotations;
public sealed class MicroserviceOptions
{
public const string MicroserviceOptionsSectionName = "Microservice";
[Required]
required public string MicroserviceName { get; set; }
[Required]
[Range(0, int.MaxValue, ErrorMessage = "StartupWarmupSeconds must be 0 or positive.")]
required public int StartupWarmupSeconds { get; set; } = 20;
}
}
Usage in REST DTOs¶
// CreateMicroserviceAggregateRootRequest.cs
namespace ConnectSoft.MicroserviceTemplate.ServiceModel
{
using System.ComponentModel.DataAnnotations;
public class CreateMicroserviceAggregateRootRequest
{
[Required]
[StringLength(200)]
public string Name { get; init; }
[Required]
[EmailAddress]
public string CreatedBy { get; init; }
[Required]
public string Source { get; init; }
}
}
Automatic Validation in ASP.NET Core¶
When DataAnnotations are applied to request models, ASP.NET Core automatically validates during model binding:
[HttpPost]
[ProducesResponseType(typeof(CreateAggregateResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(
[FromBody] CreateMicroserviceAggregateRootRequest request)
{
// Request is automatically validated before this method executes
// If invalid, returns 400 Bad Request with ValidationProblemDetails
var input = request.ToInput();
var result = await _service.CreateAsync(input);
return result.IsSuccess
? Ok(result.ToResponse())
: BadRequest(result);
}
Response Format (for invalid requests):
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required."],
"CreatedBy": ["CreatedBy is not a valid email address."]
}
}
Manual Validation with Helper¶
For scenarios outside ASP.NET Core model binding (gRPC, background jobs, messaging), use the validation helper:
// ServiceModelInputValidationHelper.cs
public static class ServiceModelInputValidationHelper
{
public static List<ValidationResult> Validate(object instance)
{
var context = new ValidationContext(instance);
var results = new List<ValidationResult>();
Validator.TryValidateObject(
instance,
context,
results,
validateAllProperties: true);
return results;
}
}
Usage:
var request = new CreateMicroserviceAggregateRootRequest
{
Name = "",
CreatedBy = "invalid-email"
};
var validationErrors = ServiceModelInputValidationHelper.Validate(request);
if (validationErrors.Any())
{
throw new ValidationException("Request validation failed.", validationErrors);
}
Options Validation with Source Generators¶
Purpose¶
Source-generated validators provide compile-time validation for configuration options, ensuring that invalid configuration is caught at startup rather than runtime.
Technology¶
The [OptionsValidator] attribute from Microsoft.Extensions.Options.Validation.Generators automatically generates IValidateOptions<T> implementations at compile time.
Options Class Definition¶
// MicroserviceOptions.cs
namespace ConnectSoft.MicroserviceTemplate.Options
{
using System.ComponentModel.DataAnnotations;
public sealed class MicroserviceOptions
{
public const string MicroserviceOptionsSectionName = "Microservice";
[Required]
required public string MicroserviceName { get; set; }
[Required]
[Range(0, int.MaxValue)]
required public int StartupWarmupSeconds { get; set; } = 20;
}
}
Source-Generated Validator¶
// ValidateMicroserviceOptions.cs
namespace ConnectSoft.MicroserviceTemplate.Options
{
using Microsoft.Extensions.Options;
/// <summary>
/// Source-generated validator for MicroserviceOptions.
/// Validation logic is generated automatically based on DataAnnotations.
/// </summary>
[OptionsValidator]
public partial class ValidateMicroserviceOptions : IValidateOptions<MicroserviceOptions>
{
// Implementation is generated by the source generator
}
}
Key Features: - Zero Boilerplate: No manual implementation required - Compile-Time Generation: Validation logic generated at compile time - DataAnnotations-Based: Uses attributes on the options class - Type-Safe: Full IntelliSense and compile-time checking
Options Registration¶
Options are registered with validation:
// OptionsExtensions.cs
services
.AddOptions<MicroserviceOptions>()
.BindConfiguration(MicroserviceOptions.MicroserviceOptionsSectionName, options =>
{
options.ErrorOnUnknownConfiguration = true;
})
.ValidateDataAnnotations()
.ValidateOnStart();
// Register source-generated validator
services.AddSingleton<IValidateOptions<MicroserviceOptions>, ValidateMicroserviceOptions>();
services.ActivateSingleton<IValidateOptions<MicroserviceOptions>>();
Validation Flow:
1. Configuration is bound from appsettings.json
2. DataAnnotations are validated
3. Source-generated validator runs
4. If validation fails, startup is aborted with clear error message
Startup Validation Failure¶
If configuration is invalid:
Startup fails with:
System.InvalidOperationException: Failed to validate options: MicroserviceOptions
The MicroserviceName field is required.
The StartupWarmupSeconds field must be 0 or greater.
Benefits: - Fail-Fast: Invalid configuration prevents service startup - Clear Errors: Specific validation failures are reported - Type Safety: Compile-time checking of validator implementation - No Runtime Overhead: Validation logic generated at compile time
REST API Validation¶
Integration¶
REST API validation combines DataAnnotations and FluentValidation:
- Model Binding: DataAnnotations validate during ASP.NET Core model binding
- FluentValidation: Manual validation is performed in gRPC interceptors and domain model validation
Request Model¶
// CreateMicroserviceAggregateRootRequest.cs
public class CreateMicroserviceAggregateRootRequest
{
[Required]
[StringLength(200)]
public string Name { get; init; }
[Required]
[EmailAddress]
public string CreatedBy { get; init; }
}
FluentValidator for Request¶
// CreateMicroserviceAggregateRootRequestValidator.cs
public class CreateMicroserviceAggregateRootRequestValidator
: AbstractValidator<CreateMicroserviceAggregateRootRequest>
{
public CreateMicroserviceAggregateRootRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200)
.Must(name => !name.Contains("badword"))
.WithMessage("Name contains invalid characters.");
RuleFor(x => x.CreatedBy)
.NotEmpty()
.EmailAddress();
}
}
Controller Usage¶
[ApiController]
[Route("api/[controller]")]
public class MicroserviceAggregateRootsController : ControllerBase
{
private readonly IMicroserviceAggregateRootsProcessor processor;
[HttpPost]
[ProducesResponseType(typeof(CreateAggregateResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(
[FromBody] CreateMicroserviceAggregateRootRequest request)
{
// Request is automatically validated before this executes
// If invalid, ASP.NET Core returns 400 Bad Request automatically
var input = request.ToInput();
var result = await this.processor.CreateMicroserviceAggregateRootAsync(input);
return result.IsSuccess
? Ok(result.ToResponse())
: BadRequest(result);
}
}
Error Response Format¶
Invalid requests return ValidationProblemDetails:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"Name is required.",
"Name contains invalid characters."
],
"CreatedBy": [
"CreatedBy is not a valid email address."
]
},
"traceId": "00-abc123def456-789ghi012jkl-01"
}
Messaging Validation¶
NServiceBus Validation¶
NServiceBus supports DataAnnotations validation for message contracts:
// NServiceBusExtensions.cs
endpointConfiguration.UseDataAnnotationsValidation(
incoming: true, // Validate incoming messages
outgoing: true // Validate outgoing messages
);
Message Contract¶
// MicroserviceAggregateRootCreatedEvent.cs
namespace ConnectSoft.MicroserviceTemplate.MessagingModel
{
using System.ComponentModel.DataAnnotations;
using NServiceBus;
public class MicroserviceAggregateRootCreatedEvent : IEvent
{
[Required]
public Guid ObjectId { get; init; }
[Required]
[StringLength(200)]
public string Name { get; init; }
[Required]
public DateTimeOffset CreatedAt { get; init; }
}
}
Handler with Validation¶
// MicroserviceAggregateRootCreatedHandler.cs
public class MicroserviceAggregateRootCreatedHandler
: IHandleMessages<MicroserviceAggregateRootCreatedEvent>
{
public Task Handle(
MicroserviceAggregateRootCreatedEvent message,
IMessageHandlerContext context)
{
// Message is automatically validated before handler executes
// If invalid, NServiceBus throws validation exception
// Process valid message
// ...
return Task.CompletedTask;
}
}
FluentValidation in Handlers¶
For complex validation, inject FluentValidation validators:
public class ValidatingMessageHandler : IHandleMessages<SomeCommand>
{
private readonly IValidator<SomeCommand> validator;
public ValidatingMessageHandler(IValidator<SomeCommand> validator)
{
this.validator = validator;
}
public async Task Handle(SomeCommand message, IMessageHandlerContext context)
{
var result = await this.validator.ValidateAsync(message);
if (!result.IsValid)
{
throw new ValidationException(result.Errors);
}
// Process valid message
// ...
}
}
Domain Service Validation¶
Purpose¶
Validation in domain services ensures that inputs are valid before business logic execution, providing defense-in-depth and transport independence.
Processor Validation¶
// DefaultMicroserviceAggregateRootsProcessor.cs
public class DefaultMicroserviceAggregateRootsProcessor : IMicroserviceAggregateRootsProcessor
{
private readonly IValidator<CreateMicroserviceAggregateRootInput> createValidator;
private readonly IMicroserviceAggregateRootsRepository repository;
private readonly IUnitOfWork unitOfWork;
public async Task<IMicroserviceAggregateRoot> CreateMicroserviceAggregateRoot(
CreateMicroserviceAggregateRootInput input,
CancellationToken token = default)
{
// Validate input before processing
await this.createValidator.ValidateAndThrowAsync(input, token);
// Check business rules
var existing = await this.repository.GetByIdAsync(input.ObjectId, token);
if (existing != null)
{
throw new MicroserviceAggregateRootAlreadyExistsException(input.ObjectId);
}
// Create and persist entity
var entity = new MicroserviceAggregateRootEntity
{
ObjectId = input.ObjectId
};
this.unitOfWork.ExecuteTransactional(() =>
{
this.repository.Insert(entity);
});
return entity;
}
}
Benefits: - Transport Independence: Works across REST, gRPC, messaging, background jobs - Defense in Depth: Validation at multiple layers - Domain Consistency: Ensures rules apply even in cross-service calls - Early Failure: Prevents invalid state before persistence
Testing¶
Unit Testing Validators¶
Validators are tested as first-class components:
// CreateMicroserviceAggregateRootInputValidatorUnitTests.cs
namespace ConnectSoft.MicroserviceTemplate.UnitTests.DomainModel.Validators
{
using ConnectSoft.MicroserviceTemplate.DomainModel;
using ConnectSoft.MicroserviceTemplate.DomainModel.Impl.Validators;
using FluentAssertions;
using Xunit;
public sealed class CreateMicroserviceAggregateRootInputValidatorUnitTests
{
private readonly CreateMicroserviceAggregateRootInputValidator validator = new();
[Fact]
public void ValidInput_Passes()
{
// Arrange
var input = new CreateMicroserviceAggregateRootInput
{
ObjectId = Guid.NewGuid()
};
// Act
var result = this.validator.Validate(input);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void EmptyObjectId_Fails()
{
// Arrange
var input = new CreateMicroserviceAggregateRootInput
{
ObjectId = Guid.Empty
};
// Act
var result = this.validator.Validate(input);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.PropertyName == "ObjectId" &&
e.ErrorMessage.Contains("cannot be Guid.Empty"));
}
[Theory]
[InlineData("")]
[InlineData(null)]
public void InvalidProperty_Fails(string invalidValue)
{
// Arrange
var input = new CreateMicroserviceAggregateRootInput
{
ObjectId = Guid.NewGuid(),
SomeProperty = invalidValue
};
// Act
var result = this.validator.Validate(input);
// Assert
result.IsValid.Should().BeFalse();
}
}
}
Test Patterns¶
Positive Tests¶
[Fact]
public void ValidInput_Passes()
{
var input = CreateValidInput();
var result = validator.Validate(input);
result.IsValid.Should().BeTrue();
}
Negative Tests¶
[Fact]
public void MissingRequiredField_Fails()
{
var input = new CreateInput { Name = "" };
var result = validator.Validate(input);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Name");
}
Parametrized Tests¶
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData(" ")]
public void InvalidName_Fails(string invalidName)
{
var input = new CreateInput { Name = invalidName };
var result = validator.Validate(input);
result.IsValid.Should().BeFalse();
}
Custom Rule Tests¶
[Fact]
public void CustomRule_ValidatesCorrectly()
{
var input = new CreateInput { Username = "taken" };
// Mock repository to return existing user
_mockRepository.Setup(r => r.Exists("taken")).Returns(true);
var result = validator.Validate(input);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.ErrorMessage.Contains("already taken"));
}
Testing Service Model Validation¶
Test DataAnnotations validation manually:
[Fact]
public void InvalidRequest_ReturnsValidationErrors()
{
// Arrange
var request = new CreateMicroserviceAggregateRootRequest
{
Name = "",
CreatedBy = "invalid-email"
};
// Act
var results = ServiceModelInputValidationHelper.Validate(request);
// Assert
results.Should().NotBeEmpty();
results.Should().Contain(r => r.MemberNames.Contains("Name"));
results.Should().Contain(r => r.MemberNames.Contains("CreatedBy"));
}
Integration Testing¶
Test validation in full request pipeline:
[Fact]
public async Task InvalidRequest_Returns400BadRequest()
{
// Arrange
var request = new CreateMicroserviceAggregateRootRequest
{
Name = "",
CreatedBy = "invalid-email"
};
// Act
var response = await _client.PostAsJsonAsync("/api/aggregates", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem.Errors.Should().ContainKey("Name");
problem.Errors.Should().ContainKey("CreatedBy");
}
Best Practices¶
Do's¶
-
Validate Early and Often
-
Use Appropriate Validation Technology
-
Provide Clear Error Messages
-
Test Validators Thoroughly
-
Use Rule Sets for Context
-
Validate Configuration at Startup
Don'ts¶
-
Don't Skip Validation
// ❌ BAD - No validation public async Task<Result> CreateAsync(CreateInput input) { // Directly process without validation var entity = new Entity(input.Name); } // ✅ GOOD - Always validate public async Task<Result> CreateAsync(CreateInput input) { await validator.ValidateAndThrowAsync(input); var entity = new Entity(input.Name); } -
Don't Mix Business Logic in Validators
-
Don't Use Magic Strings
-
Don't Forget Async Validation
-
Don't Validate at Wrong Layer
// ❌ BAD - Domain entity validating input public class Entity { public Entity(CreateInput input) { if (string.IsNullOrEmpty(input.Name)) // Validation in domain! throw new Exception(); } } // ✅ GOOD - Validate in application layer public async Task<Entity> CreateAsync(CreateInput input) { await validator.ValidateAndThrowAsync(input); // Validation here return new Entity(input.Name); // Domain assumes valid input }
Troubleshooting¶
Issue: Validator Not Executing¶
Symptom: Validation rules not being applied, invalid data passes through.
Solutions:
1. Verify validator is registered: services.AddFluentValidation()
2. Check validator inherits from AbstractValidator<T>
3. Ensure validator is in scanned assembly
4. Verify manual validation is called where needed (e.g., in gRPC interceptors)
5. Check controller/model is properly decorated
Issue: Validation Fails in Tests¶
Symptom: Tests fail with validation errors unexpectedly.
Solutions: 1. Ensure test data meets validation requirements 2. Use test data builders or factories 3. Check for required fields that might be missing 4. Verify validator configuration matches test expectations 5. Consider using rule sets to skip certain validations in tests
Issue: ValidationOptions Not Applied¶
Symptom: Fail-fast behavior not working as configured.
Solutions:
1. Verify ValidationOptions is bound from configuration
2. Check options are applied to ValidatorOptions.Global
3. Ensure options are loaded before validators execute
4. Verify configuration section name matches exactly
Issue: Source-Generated Validator Not Working¶
Symptom: Options validation not running or failing at compile time.
Solutions:
1. Verify [OptionsValidator] attribute is present
2. Check package Microsoft.Extensions.Options.Validation.Generators is referenced
3. Ensure validator is registered: services.AddSingleton<IValidateOptions<T>, TValidator>()
4. Verify .ValidateOnStart() is called
5. Check DataAnnotations are on options class properties
Issue: NServiceBus Validation Not Working¶
Symptom: Invalid messages pass through without validation.
Solutions:
1. Verify UseDataAnnotationsValidation() is called on endpoint configuration
2. Check message contracts have DataAnnotations attributes
3. Ensure validation is enabled for both incoming and outgoing messages
4. Verify NServiceBus version supports validation
Summary¶
Validation in the ConnectSoft Microservice Template provides:
- ✅ Layered Enforcement: Validation at API, Application, Domain, and Configuration layers
- ✅ Dual Technology Support: FluentValidation for complex rules, DataAnnotations for simple structure
- ✅ Fail-Fast Configuration: Runtime control over validation behavior via
ValidationOptions - ✅ Startup Validation: Source-generated validators ensure configuration correctness
- ✅ Transport Independence: Validation works across REST, gRPC, messaging, and background jobs
- ✅ Testability: Validators are first-class components with dedicated unit tests
- ✅ Automatic Integration: Seamless integration with ASP.NET Core model binding
- ✅ Comprehensive Coverage: Validation from configuration to domain commands
By following these patterns, teams can:
- Prevent Invalid State: Catch errors before they reach business logic
- Maintain Consistency: Ensure rules apply across all entry points
- Improve Developer Experience: Clear error messages and validation feedback
- Enable Safe Evolution: Validation rules evolve with business requirements
- Build Reliable Services: Validation is the foundation of system integrity
Validation is not just a library—it's a platform-wide contract that ensures reliability, safety, and resilience across all ConnectSoft microservices.