Myth.Guard
4.4.3
dotnet add package Myth.Guard --version 4.4.3
NuGet\Install-Package Myth.Guard -Version 4.4.3
<PackageReference Include="Myth.Guard" Version="4.4.3" />
<PackageVersion Include="Myth.Guard" Version="4.4.3" />
<PackageReference Include="Myth.Guard" />
paket add Myth.Guard --version 4.4.3
#r "nuget: Myth.Guard, 4.4.3"
#:package Myth.Guard@4.4.3
#addin nuget:?package=Myth.Guard&version=4.4.3
#tool nuget:?package=Myth.Guard&version=4.4.3
<img style="float: right;" src="myth-guard-logo.png" alt="drawing" width="250"/>
Myth.Guard
A powerful, fluent .NET validation library designed for enterprise applications. Built with clean architecture principles, Myth.Guard provides declarative validation with context-awareness, async service integration, and automatic ASP.NET Core middleware.
🎯 Why Myth.Guard?
Data validation is where most bugs hide. Invalid input crashes systems, corrupts databases, exposes security vulnerabilities, and costs businesses millions. Yet most .NET validation solutions force impossible tradeoffs: attributes are inflexible and can't access services, FluentValidation separates rules from entities breaking DDD principles, manual if-checks scatter validation across the codebase making it unmaintainable. Myth.Guard solves this with declarative validation that lives with your domain models, combining the best of all approaches while adding context-awareness, async service integration, and automatic API error handling.
The Problem
Validation is Scattered, Inconsistent, and Broken
// Validation scattered across layers
public class UserController : ControllerBase {
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] CreateUserDto dto) {
// Controller validation - duplicated for every endpoint
if (string.IsNullOrEmpty(dto.Email)) {
return BadRequest("Email is required");
}
if (dto.Age < 0 || dto.Age > 150) {
return BadRequest("Invalid age");
}
// Service layer validation - different rules!
var existingUser = await _userService.GetByEmailAsync(dto.Email);
if (existingUser != null) {
return Conflict("Email already exists");
}
// Repository layer validation - even more rules!
try {
await _repository.AddAsync(user);
} catch (DbUpdateException ex) {
// Database constraint violated - which one? No idea.
return StatusCode(500, "Database error");
}
return Ok(user);
}
}
Problems:
- Scattered: Validation in controller, service, repository, database
- Inconsistent: Different rules for Create vs Update duplicated everywhere
- Can't access services: Need to check database? Write manual code
- Poor error messages: Generic "Bad Request" or cryptic DB errors
- Not testable: Validation mixed with business logic
- No DDD: Entities can be in invalid state, anemic domain models
The Solution
Declarative, Context-Aware Validation with Domain Models
// Validation lives with the model - DDD principle
public class CreateUserDto : IValidatable<CreateUserDto> {
public string Name { get; set; }
public string Email { get; set; }
public int Age { get; set; }
public void Validate(ValidationBuilder<CreateUserDto> builder, ValidationContextKey? context = null) {
// Global rules - apply to all contexts
builder.For(Name, x => x.NotEmpty().MinimumLength(2).MaximumLength(100));
builder.For(Email, x => x.NotEmpty().Email());
builder.For(Age, x => x.GreaterThan(0).LessThan(150));
// Context-specific rules - Create only
builder.InContext(ValidationContextKey.Create, b => {
b.For(Email, x => x
.RespectAsync(async (email, ct, sp) => {
var userService = sp.GetRequiredService<IUserService>();
return !await userService.EmailExistsAsync(email, ct);
})
.WithMessage("Email already exists")
.WithStatusCode(409)); // Conflict
});
}
}
Global rules always execute. Rules defined outside any
InContextblock run on everyValidateAsync()call, regardless of which context key is passed.InContextis additive: it appends extra rules on top of the globals when the matching context is active. CallingValidateAsync(dto, ValidationContextKey.Create)runs the global rules first, then the Create-specific rules.
// Controller - clean, one line
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] CreateUserDto dto) {
await _validator.ValidateAsync(dto, ValidationContextKey.Create);
// If we get here, dto is valid - guaranteed
var user = await _userService.CreateAsync(dto);
return Ok(user);
}
Benefits:
- DDD-aligned: Validation with domain model, entities always valid
- Context-aware: Different rules for Create/Update/Delete on same model
- Service access: Async database/API checks via DI
- Automatic errors: Middleware returns structured JSON with field errors
- Testable: Validation is a pure function, easy to unit test
- Consistent: One source of truth for rules
Why Choose Myth.Guard?
| Aspect | Myth.Guard | Data Annotations | FluentValidation | Manual If-Checks |
|---|---|---|---|---|
| Location | With domain model (DDD) | On properties (scattered) | Separate validator class | Throughout codebase |
| Context-Aware | Built-in (Create/Update/Delete) | No | Manual (validator per context) | Copy-paste |
| Async Service Access | Native with DI | No | Yes (complex setup) | Manual |
| Error Messages | Structured JSON automatic | Generic | Manual setup | Manual |
| Type Safety | Fluent with lambdas | Strings (magic) | Fluent | Manual |
| Testability | Easy (pure function) | Hard (reflection) | Medium | Hard (coupled) |
| HTTP Status Codes | Configurable per rule | No | No | Manual |
| Multiple Values | Parallel validation (fast) | Sequential | Sequential | Sequential |
| Stop on Failure | Per-field control | No | Yes | Manual |
Real-World Applications
E-Commerce Product Catalog Different validation for Create (SKU uniqueness), Update (price changes require approval), Delete (check orders exist). All in one model.
Financial Services KYC Async validation calls external credit check APIs, sanctions lists, ID verification services. Retry transient failures automatically.
SaaS User Management Multi-tenant validation: email unique per tenant, role valid for tenant tier, quota limits enforced. Context-aware per operation.
Healthcare EHR Systems HIPAA-compliant validation with audit trail. Different rules per user role (doctor vs nurse). Structured errors for UI display.
Government Form Processing Complex validation rules calling external APIs (address validation, tax ID check). Configurable HTTP status codes per regulation.
Key Differentiators
🏗️ Domain-Driven Design Native Validation is part of your domain model, not infrastructure. Entities can never be in invalid state. Aligns with DDD tactical patterns.
🎯 Context-Aware Validation
Same entity, different rules per operation. Create checks uniqueness, Update skips it, Delete checks dependencies. Zero duplication.
⚡ Async Service Integration Call database, APIs, external services within validation rules. Full dependency injection access. Automatic retry for transient failures.
📋 Structured Error Responses Automatic middleware returns RFC 9457 Problem Details with field-level errors, codes, HTTP status. Perfect for modern SPAs.
🚀 Parallel Multi-Validation
Validator.ValidateMultipleAsync() validates multiple objects in parallel (like Task.WhenAll). Validate batch imports 10x faster.
🔧 Standalone Field Validation
Guard.For(email).Email().NotEmpty() validates single values outside model context. Great for utility functions.
Conceptual Foundations
Domain-Driven Design (DDD) Eric Evans' "always-valid entity" pattern. Validation is domain logic, belongs with domain models, not infrastructure.
Fluent Interface Pattern
Method chaining for readable validation: .NotEmpty().MinimumLength(2).MaximumLength(100). Inspired by FluentValidation and LINQ.
Specification Pattern Each rule is a specification that can be composed. Rules are predicates with error messages.
Railway-Oriented Programming
Validation returns ValidationResult (success/failure), integrates with Result<T> pattern from Myth.Flow.
RFC 9457 Problem Details Standard HTTP error format with type, title, status, detail, instance. Field-level extensions for validation errors.
Context-Driven Validation Different validation rules based on operation context (Create/Update/Delete/Search). Eliminates rule duplication.
Business Value
For Developers
- 80% less validation code by eliminating duplication across contexts
- DDD-aligned architecture with validation as domain concern
- Easy async validation with full DI access
- Fast testing with pure validation functions
For Architects
- Enforce data integrity at domain layer, not just UI or database
- Consistent error handling across all APIs via middleware
- Compliance ready with audit-friendly structured errors
- Scalable validation with parallel batch processing
For DevOps/SRE
- Structured logs with validation failure details
- Consistent HTTP status codes for monitoring/alerting
- Reduced database load by catching invalid data early
- Better error tracking with error codes and field info
For Product Teams
- Better UX with structured field-level errors for forms
- Faster development with reusable, context-aware rules
- Fewer bugs from invalid data reaching database
- Compliance (GDPR, HIPAA, SOX) with validation audit trail
Key Features
- Declarative Fluent API: Write readable validation rules with chainable methods
- Multi-Validation: Validate multiple values simultaneously with parallel execution (similar to Task.WhenAll)
- Standalone Validation: Use Guard.For() for independent field validation outside model context
- Context-Aware Validation: Different rules for Create, Update, Delete operations on the same entity
- Async Service Integration: Access dependency injection for database or API validation
- Global Exception Handler: Configure custom exception mappings with status codes and response formats
- Automatic Error Handling: ASP.NET Core middleware with structured JSON responses
- 100+ Built-in Rules: Comprehensive validation for strings, numbers, collections, dates, booleans, enums
- Nullable Type Support: Full support for nullable value types with dedicated rules
- Custom Rules: Easy extensibility with
Respect()andRespectAsync()methods - Conditional Validation: Field-level and entity-level conditional rules
- Stop on Failure: Optimize validation by stopping after critical failures
- HTTP Status Customization: Configure default status codes globally and override per validation error
Installation
dotnet add package Myth.Guard
Quick Start
1. Define Validation on Your Entity
public class CreateUserDto : IValidatable<CreateUserDto>
{
public string Name { get; set; }
public string Email { get; set; }
public int Age { get; set; }
public List<string> Tags { get; set; }
public void Validate( ValidationBuilder<CreateUserDto> builder, ValidationContextKey? context = null )
{
builder.For( Name, x => x.NotEmpty().MinimumLength( 2 ).MaximumLength( 100 ) );
builder.For( Email, x => x.NotEmpty().Email() );
builder.For( Age, x => x.GreaterThan( 0 ).LessThan( 150 ) );
builder.For( Tags, x => x.NotEmpty().CountBetween( 1, 10 ) );
}
}
2. Configure Services and Middleware
var builder = WebApplication.CreateBuilder( args );
// Basic configuration
builder.Services.AddGuard();
// Advanced configuration with default validation status code
builder.Services.AddGuard( config => config
.UseDefaultStatusCode( 422 ) // UnprocessableEntity for validation errors
.AutoGuardCommonExceptions()
);
var app = builder.Build();
app.UseGuard(); // Adds automatic validation exception handling
app.MapControllers();
app.Run();
3. Use in Controllers
public class UserController : ControllerBase
{
private readonly IValidator _validator;
public UserController( IValidator validator )
{
_validator = validator;
}
[HttpPost( "users" )]
public async Task<IActionResult> CreateUser( [FromBody] CreateUserDto request )
{
// Validate and throw ValidationException on failure
await _validator.ValidateAsync( request, ValidationContextKey.Create );
// Or validate and check result without throwing
var result = await _validator.ValidateAndReturnAsync( request, ValidationContextKey.Create );
if ( !result.IsValid )
return BadRequest( new { errors = result.Errors } );
// Process user creation...
return Ok( new { message = "User created successfully" } );
}
}
Automatic Error Response
With app.UseGuard() middleware, validation exceptions are automatically formatted following RFC 9457 (Problem Details for HTTP APIs):
{
"type": "https://github.com/paulaolileal/myth/blob/main/docs/errors/validation.md",
"title": "One or more validation errors occurred",
"status": 400,
"instance": "/api/users",
"traceId": "00-abc123...",
"errors": {
"email": ["Email is required"],
"age": ["Value must be greater than 0"]
}
}
Validation Rules Reference
String Rules
builder.For( Email, x => x
.NotEmpty()
.Email()
.MaximumLength( 254 ) );
builder.For( Name, x => x
.NotEmpty()
.MinimumLength( 2 )
.MaximumLength( 100 )
.OnlyLetters() );
builder.For( Password, x => x
.NotEmpty()
.MinimumLength( 8 )
.Matches( new Regex( @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*$" ) )
.WithMessage( "Password must contain uppercase, lowercase, and digit" ) );
builder.For( PhoneNumber, x => x
.NotEmpty()
.Matches( new Regex( @"^\+\d{1,3}\d{10,14}$" ) ) );
Available String Rules:
NotEmpty()- Not null, empty, or whitespaceMinimumLength(int),MaximumLength(int),LengthBetween(int, int)- Length validationEmail(),Url()- Format validationOnlyLetters(),OnlyNumbers(),Alphanumeric()- Character type validationStartsWith(string),EndsWith(string),Contains(string)- Substring checksMatches(Regex)- Regex pattern matchingEqualsTo(string),BeOneOf(params string[])- Enumeration checksAvailableCharacters(params char[]),ForbiddenCharacters(params char[])- Character whitelist/blacklistNoSymbols(char[]?)- Symbol validation
Numeric Rules
builder.For( Age, x => x
.GreaterThan( 0 )
.LessThan( 150 ) );
builder.For( Salary, x => x
.GreaterOrEquals( 0 )
.LessThan( 1000000m ) );
builder.For( Score, x => x
.Between( 0, 100 )
.When( score => score.HasValue ) );
builder.For( Quantity, x => x
.Positive()
.NotZero() );
Available Numeric Rules (int, long, decimal, double, float, etc.):
GreaterThan(T),GreaterOrEquals(T)- Minimum value validationLessThan(T),LessOrEquals(T)- Maximum value validationBetween(T min, T max)- Range validation (inclusive)Positive(),Negative()- Sign validationZero(),NotZero()- Zero value checks
Collection Rules
builder.For( Tags, x => x
.NotEmpty()
.CountBetween( 1, 10 )
.All( tag => !string.IsNullOrWhiteSpace( tag ) )
.Distinct() );
builder.For( UserRoles, x => x
.NotEmpty()
.Any( role => role == "Admin" || role == "User" )
.None( role => role == "Banned" ) );
builder.For( Products, x => x
.DistinctBy( p => p.Sku ) );
Available Collection Rules:
NotEmpty()- Collection not null and has elementsCountBetween(int, int),CountGreaterThan(int),CountLessThan(int)- Size validationAll<T>(Func<T, bool>)- All elements match conditionAny<T>(Func<T, bool>)- At least one matchesNone<T>(Func<T, bool>)- No elements matchDistinct<T>()- No duplicates (using default equality)DistinctBy<T, TKey>(Func<T, TKey>)- No duplicates by key
DateTime and DateOnly Rules
builder.For( BirthDate, x => x
.Past()
.After( new DateTime( 1900, 1, 1 ) ) );
builder.For( ScheduledDate, x => x
.Future()
.Before( DateTime.Now.AddYears( 1 ) ) );
builder.For( AppointmentDate, x => x
.Between( DateTime.Today, DateTime.Today.AddDays( 30 ) ) );
builder.For( EventDate, x => x.Today() );
Available DateTime/DateOnly Rules:
Past(),Future(),Today()- Temporal validationAfter(DateTime),Before(DateTime)- Comparison (exclusive)AfterOrEquals(DateTime),BeforeOrEquals(DateTime)- Comparison (inclusive)Between(DateTime, DateTime)- Date range (inclusive)
Boolean and Enum Rules
builder.For( IsActive, x => x.IsTrue() );
builder.For( IsDeleted, x => x.IsFalse() );
builder.For( Role, x => x.BeInEnum<UserRole>() );
builder.For( Status, x => x.BeOneOf( Status.Active, Status.Pending ) );
Constant Rules
Validate values and names against Myth.Commons.ValueObjects.Constant<TConstant, TValue> types:
// Define your constants
public class Status : Constant<Status, string> {
public static readonly Status Active = new( "Active", "A" );
public static readonly Status Inactive = new( "Inactive", "I" );
public static readonly Status Pending = new( "Pending", "P" );
public Status( string name, string value ) : base( name, value ) { }
}
public class Priority : Constant<Priority, int> {
public static readonly Priority Low = new( "Low", 1 );
public static readonly Priority Medium = new( "Medium", 5 );
public static readonly Priority High = new( "High", 10 );
public Priority( string name, int value ) : base( name, value ) { }
}
// Validate constant values and names
builder.For( StatusCode, x => x
.NotEmpty()
.ExistsInConstant<Status, string>() );
builder.For( StatusName, x => x
.NotEmpty()
.NameExistsInConstant<Status, string>() );
builder.For( PriorityLevel, x => x
.ExistsInConstant<Priority, int>() );
builder.For( PriorityName, x => x
.NotEmpty()
.NameExistsInConstant<Priority, int>() );
Available Constant Rules:
ExistsInConstant<TConstant, TValue>()- Validates that a value exists in the constant definitionNameExistsInConstant<TConstant, TValue>()- Validates that a name exists in the constant definition
Error Messages:
- Value error:
"Value 'X' is not valid. Valid options are: A: Active | I: Inactive | P: Pending" - Name error:
"Name 'Unknown' is not valid. Valid options are: 1: Low | 5: Medium | 10: High"
Generic Rules (All Types)
builder.For( UserId, x => x
.NotNull()
.NotDefault() );
builder.For( Email, x => x
.NotNull()
.NotEqualsTo( "admin@example.com" ) );
Available Generic Rules:
NotNull(),BeNull()- Null checksEqualsTo(T),NotEqualsTo(T)- Value comparisonBeDefault(),NotDefault()- Default value checksRespect(Func<T, bool>)- Custom sync validationRespectAsync(Func<T, CancellationToken, IServiceProvider, Task<bool>>)- Custom async validationRespect<TEntity>(Func<T, TEntity, bool>)- Custom sync validation with entity accessRespectAsync<TEntity>(Func<T, TEntity, CancellationToken, IServiceProvider, Task<bool>>)- Custom async validation with entity access
Nullable Type Support
All numeric, DateTime, and boolean rules have nullable versions. NotDefault() also works natively on nullable structs (Guid?, int?, etc.):
builder.For( OptionalAge, x => x
.GreaterThan( 18 )
.When( age => age.HasValue ) );
builder.For( OptionalDate, x => x
.Future()
.When( date => date.HasValue ) );
builder.For( OptionalFlag, x => x.IsTrue() );
// Nullable struct: null passes, Guid.Empty fails
builder.For( UserId, x => x
.NotDefault()
.WithMessage( "UserId must not be empty" ) );
Context-Aware Validation
Define different validation rules for different operations:
public class UserDto : IValidatable<UserDto>
{
public string Email { get; set; }
public int Age { get; set; }
public bool IsActive { get; set; }
public string Password { get; set; }
public void Validate( ValidationBuilder<UserDto> builder, ValidationContextKey? context = null )
{
// Global rules (apply to all contexts)
builder.For( Email, x => x.NotEmpty().Email() );
builder.For( Age, x => x.GreaterThan( 0 ).LessThan( 150 ) );
// Create-specific rules
builder.InContext( ValidationContextKey.Create, b =>
{
b.For( Email, x => x
.RespectAsync( async ( email, ct, sp ) =>
{
var userService = sp.GetRequiredService<IUserService>();
return await userService.IsEmailAvailableAsync( email, ct );
} )
.WithMessage( "Email already exists" )
.WithStatusCode( HttpStatusCode.Conflict ) );
b.For( Password, x => x
.NotEmpty()
.MinimumLength( 8 )
.Matches( new Regex( @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*$" ) ) );
b.For( IsActive, x => x.IsTrue() );
} );
// Update-specific rules
builder.InContext( ValidationContextKey.Update, b =>
{
b.For( Age, x => x.GreaterOrEquals( 18 ) );
// Password is optional on update
} );
// Delete-specific rules
builder.InContext( ValidationContextKey.Delete, b =>
{
b.For( IsActive, x => x.IsFalse()
.WithMessage( "Cannot delete active user" ) );
} );
}
}
// Usage with different contexts
await validator.ValidateAsync( user, ValidationContextKey.Create );
await validator.ValidateAsync( user, ValidationContextKey.Update );
await validator.ValidateAsync( user, ValidationContextKey.Delete );
Pre-defined Contexts
ValidationContextKey.Default // Default context
ValidationContextKey.Create // For creation operations
ValidationContextKey.Update // For update operations
ValidationContextKey.Delete // For deletion operations
ValidationContextKey.GetByField // For field-based queries
ValidationContextKey.GetAll // For listing operations
ValidationContextKey.Search // For search operations
ValidationContextKey.Activate // For activation operations
ValidationContextKey.Deactivate // For deactivation operations
// Custom contexts
ValidationContextKey.Custom( "BulkImport" )
Conditional Validation
Execute rules based on conditions:
Field-Level Conditions
builder.For( PhoneNumber, x => x
.NotEmpty()
.When( phone => !string.IsNullOrEmpty( phone ) ) // Only validate if not empty
.Matches( new Regex( @"^\+\d{1,3}\d{10,14}$" ) ) );
builder.For( Password, x => x
.NotEmpty()
.Unless( pwd => IsExternalUser ) // Skip for external users
.MinimumLength( 8 ) );
Entity-Level Conditions
builder.For( PhoneNumber, x => x
.NotEmpty()
.When<string, UserDto>( user => user.PhoneType == PhoneType.Required )
.Unless<string, UserDto>( user => user.IsVerified ) );
builder.For( Salary, x => x
.GreaterThan( 0 )
.When<decimal, EmployeeDto>( emp => emp.EmploymentType == EmploymentType.FullTime ) );
Cross-Property Validation with Entity Access
Myth.Guard provides powerful cross-property validation capabilities, allowing validation rules to access the entire entity being validated, not just the individual property value. This enables complex business rules that span multiple properties.
Basic Entity Access
Use Respect<TEntity>() and RespectAsync<TEntity>() to access the parent object:
public class LoginDto : IValidatable<LoginDto>
{
public string Email { get; set; }
public string Password { get; set; }
public bool IsActive { get; set; }
public void Validate( ValidationBuilder<LoginDto> builder, ValidationContextKey? context = null )
{
// Email validation with access to the entire LoginDto object
builder.For( Email, x => x
.NotEmpty()
.Email()
.Respect<LoginDto>( ( email, login ) => !login.IsActive || !string.IsNullOrEmpty( email ) )
.WithMessage( "Email is required for active users" ) );
// Password validation with async entity access and external service
builder.For( Password, x => x
.NotEmpty()
.MinimumLength( 6 )
.RespectAsync<LoginDto>( async ( password, login, ct, sp ) => {
var userService = sp.GetRequiredService<IUserService>();
return await userService.ValidateCredentialsAsync( login.Email, password, ct );
} )
.WithMessage( "Invalid email and password combination" )
);
}
}
Advanced Cross-Property Rules
Create complex validation logic that considers multiple properties:
public class UserProfileDto : IValidatable<UserProfileDto>
{
public string Name { get; set; }
public string Role { get; set; }
public int Age { get; set; }
public decimal Salary { get; set; }
public bool IsActive { get; set; }
public void Validate( ValidationBuilder<UserProfileDto> builder, ValidationContextKey? context = null )
{
// Name length requirements vary by role
builder.For( Name, x => x
.NotEmpty()
.Respect<UserProfileDto>( ( name, profile ) => {
return profile.Role switch {
"Admin" => name.Length >= 5,
"Manager" => name.Length >= 4,
"User" => name.Length >= 3,
_ => name.Length >= 2
};
} )
.WithMessage( "Name length requirement not met for role" )
);
// Age requirements based on role
builder.For( Age, x => x
.GreaterThan( 0 )
.Respect<UserProfileDto>( ( age, profile ) => {
return profile.Role switch {
"Admin" => age >= 25,
"Manager" => age >= 21,
"User" => age >= 18,
_ => age >= 16
};
} )
.WithMessage( "Age requirement not met for role" )
);
// Salary validation with role and activity status
builder.For( Salary, x => x
.GreaterOrEquals( 0 )
.Respect<UserProfileDto>( ( salary, profile ) => {
if ( !profile.IsActive ) return true; // Inactive users can have any salary
return profile.Role switch {
"Admin" => salary >= 80000,
"Manager" => salary >= 60000,
"User" => salary >= 30000,
_ => salary >= 20000
};
} )
.WithMessage( "Salary below minimum for active user role" )
);
}
}
Entity Access with Async Services
Combine entity access with external service validation:
public class OrderDto : IValidatable<OrderDto>
{
public int CustomerId { get; set; }
public decimal Amount { get; set; }
public string CustomerType { get; set; }
public string ShippingAddress { get; set; }
public void Validate( ValidationBuilder<OrderDto> builder, ValidationContextKey? context = null )
{
builder.For( Amount, x => x
.GreaterThan( 0 )
.RespectAsync<OrderDto>( async ( amount, order, ct, sp ) => {
var customerService = sp.GetRequiredService<ICustomerService>();
var customer = await customerService.GetByIdAsync( order.CustomerId, ct );
if ( customer == null ) return false;
// Different limits for different customer types
var maxAmount = order.CustomerType switch {
"Premium" => 50000m,
"Gold" => 25000m,
"Silver" => 10000m,
_ => 5000m
};
return amount <= maxAmount;
} )
.WithMessage( "Order amount exceeds limit for customer type" )
);
// Address validation based on customer location
builder.For( ShippingAddress, x => x
.NotEmpty()
.RespectAsync<OrderDto>( async ( address, order, ct, sp ) => {
var customerService = sp.GetRequiredService<ICustomerService>();
var addressService = sp.GetRequiredService<IAddressService>();
var customer = await customerService.GetByIdAsync( order.CustomerId, ct );
if ( customer == null ) return false;
return await addressService.IsValidForCustomerAsync( address, customer.Country, ct );
} )
.WithMessage( "Shipping address not valid for customer location" )
);
}
}
Backward Compatibility
The new entity access methods are fully backward compatible. All existing Respect() and RespectAsync() methods continue to work exactly as before:
// Existing syntax still works
builder.For( Email, x => x
.Respect( email => !string.IsNullOrEmpty( email ) && email.Contains( "@" ) ) );
builder.For( UserId, x => x
.RespectAsync( async ( id, ct, sp ) => {
var userService = sp.GetRequiredService<IUserService>();
return await userService.ExistsAsync( id, ct );
} ) );
// New entity access syntax
builder.For( Email, x => x
.Respect<UserDto>( ( email, user ) => user.IsActive || string.IsNullOrEmpty( email ) ) );
builder.For( UserId, x => x
.RespectAsync<UserDto>( async ( id, user, ct, sp ) => {
var userService = sp.GetRequiredService<IUserService>();
return await userService.HasPermissionAsync( id, user.Role, ct );
} ) );
Type Safety
The generic constraint where TEntity : class ensures type safety and provides full IntelliSense support:
// Compile-time type checking
builder.For( Email, x => x
.Respect<LoginDto>( ( email, login ) => {
// 'login' is strongly typed as LoginDto
return login.IsActive && !string.IsNullOrEmpty( email );
} ) );
// Compiler error if wrong type is used
builder.For( Email, x => x
.Respect<WrongType>( ( email, wrong ) => true ) ); // ❌ Compilation error
Performance Considerations
- Minimal overhead: Entity access adds only a single cast operation
- No reflection: Uses compile-time generics for optimal performance
- Lazy evaluation: Rules only execute when validation runs
- Service sharing: Single service provider instance shared across all rules
Async Validation with Service Provider
Access dependency injection for database or API validation:
public class CreateOrderDto : IValidatable<CreateOrderDto>
{
public int ProductId { get; set; }
public int Quantity { get; set; }
public string CustomerEmail { get; set; }
public void Validate( ValidationBuilder<CreateOrderDto> builder, ValidationContextKey? context = null )
{
builder.For( ProductId, x => x
.GreaterThan( 0 )
.RespectAsync( async ( productId, ct, sp ) =>
{
var productService = sp.GetRequiredService<IProductService>();
return await productService.ExistsAsync( productId, ct );
} )
.WithMessage( "Product does not exist" )
);
builder.For( Quantity, x => x
.GreaterThan( 0 )
.RespectAsync( async ( quantity, ct, sp ) =>
{
var productService = sp.GetRequiredService<IProductService>();
var stock = await productService.GetStockAsync( ProductId, ct );
return quantity <= stock;
} )
.WithMessage( "Insufficient stock" )
);
builder.For( CustomerEmail, x => x
.NotEmpty()
.Email()
.RespectAsync( async ( email, ct, sp ) =>
{
var customerService = sp.GetRequiredService<ICustomerService>();
return await customerService.IsActiveCustomerAsync( email, ct );
} )
.WithMessage( "Customer not found or inactive" )
.WithStatusCode( HttpStatusCode.NotFound ) );
}
}
Advanced Features
Custom Error Messages
// Static message
builder.For( Age, x => x
.GreaterThan( 18 )
.WithMessage( "User must be at least 18 years old" ) );
// Dynamic message using field value
builder.For( Age, x => x
.GreaterThan( 18 )
.WithMessage( age => $"User must be at least 18 years old, but is {age}" ) );
builder.For( Email, x => x
.Email() );
// Custom HTTP status code
builder.For( UserId, x => x
.RespectAsync( async ( id, ct, sp ) =>
{
var userService = sp.GetRequiredService<IUserService>();
return await userService.ExistsAsync( id, ct );
} )
.WithMessage( "User not found" )
.WithStatusCode( HttpStatusCode.NotFound ) ); // Returns 404 instead of 400
Stop on Failure
Stop validating a field after the first failure:
builder.For( Password, x => x
.NotEmpty()
.SetStopOnFailure() // Don't check other rules if empty
.MinimumLength( 8 )
.Matches( new Regex( @"[A-Z]" ) )
.Matches( new Regex( @"[a-z]" ) )
.Matches( new Regex( @"\d" ) ) );
Complex Business Rules
public class OrderDto : IValidatable<OrderDto>
{
public decimal Amount { get; set; }
public string CustomerType { get; set; }
public List<OrderItem> Items { get; set; }
public string CouponCode { get; set; }
public void Validate( ValidationBuilder<OrderDto> builder, ValidationContextKey? context = null )
{
builder.For( Amount, x => x
.GreaterThan( 0 )
.When<decimal, OrderDto>( order => order.Items?.Any() == true ) );
// Premium customers can have higher order amounts
builder.For( Amount, x => x
.LessThan( 10000 )
.Unless<decimal, OrderDto>( order => order.CustomerType == "Premium" ) );
builder.For( Items, x => x
.NotEmpty()
.CountBetween( 1, 50 )
.All( item => item.Quantity > 0 )
.All( item => item.Price > 0 ) );
// Coupon validation
builder.For( CouponCode, x => x
.RespectAsync( async ( coupon, ct, sp ) =>
{
if ( string.IsNullOrEmpty( coupon ) ) return true; // Optional
var couponService = sp.GetRequiredService<ICouponService>();
var isValid = await couponService.IsValidAsync( coupon, ct );
var isApplicable = await couponService.IsApplicableToOrderAsync( coupon, Amount, ct );
return isValid && isApplicable;
} )
.WithMessage( "Invalid or inapplicable coupon code" )
);
}
}
Error Handling
Validation Result
var result = await validator.ValidateAndReturnAsync( dto );
Console.WriteLine( $"Is Valid: {result.IsValid}" );
Console.WriteLine( $"Status Code: {result.StatusCode}" );
if ( !result.IsValid )
{
foreach ( var error in result.Errors )
{
Console.WriteLine( $"Field: {error.Field}" );
Console.WriteLine( $"Message: {error.Message}" );
Console.WriteLine( $"Status: {error.StatusCode}" );
}
}
Exception Handling
try
{
await validator.ValidateAsync( dto, ValidationContextKey.Create );
}
catch ( ValidationException ex )
{
var errors = ex.ValidationResult.Errors;
var statusCode = ex.ValidationResult.StatusCode;
return BadRequest( new
{
message = "Validation failed",
errors = errors.Select( e => new
{
field = e.Field,
message = e.Message
} )
} );
}
Middleware Error Response
When using app.UseGuard(), validation exceptions are automatically caught and formatted following RFC 9457 (Problem Details for HTTP APIs):
{
"type": "https://github.com/paulaolileal/myth/blob/main/docs/errors/validation.md",
"title": "One or more validation errors occurred",
"status": 409,
"instance": "/api/users",
"traceId": "00-abc123...",
"errors": {
"email": ["Email already exists"],
"password": ["Password must contain at least 8 characters"]
}
}
HTTP Status Code: The highest status code from all validation errors (e.g., if one error has 409 Conflict, the response will be 409).
Content-Type: application/problem+json (standard for RFC 9457)
Status Code Configuration
Configure default and custom status codes for validation errors:
// Configure default status code globally
builder.Services.AddGuard( config => config
.UseDefaultStatusCode( 422 ) // UnprocessableEntity
// or
.UseDefaultStatusCode( HttpStatusCode.UnprocessableEntity )
);
// Override per validation rule
public void Validate( ValidationBuilder<UserDto> builder, ValidationContextKey? context = null )
{
builder.For( Email, x => x
.NotEmpty()
.Email()
.RespectAsync( async ( email, ct, sp ) =>
{
var userService = sp.GetRequiredService<IUserService>();
return await userService.IsEmailAvailableAsync( email, ct );
} )
.WithMessage( "Email already exists" )
.WithStatusCode( HttpStatusCode.Conflict ) // 409 - Override global default
);
builder.For( Age, x => x
.GreaterThan( 0 )
.LessThan( 150 )
// Uses global default status code (422) when no WithStatusCode() is specified
);
}
Precedence Order:
- Custom status code (
.WithStatusCode()) - highest priority - Global default (
.UseDefaultStatusCode()) - medium priority - BadRequest (400) - fallback when no configuration provided
Multi-Validation
Myth.Guard provides powerful multi-validation capabilities for validating multiple values simultaneously with parallel execution, similar to Task.WhenAll. This is perfect when you need to validate multiple independent values without the overhead of separate validation calls.
Parallel Validation with Validate.AllAsync()
Validate multiple values in parallel for optimal performance:
// Basic parallel validation
var result = await Validate.AllAsync([
Guard.For(email, "Email").NotEmpty().Email(),
Guard.For(age, "Age").GreaterThan(0).LessThan(150),
Guard.For(name, "Name").NotEmpty().MinimumLength(2)
]);
if (!result.IsValid)
{
Console.WriteLine($"Found {result.ErrorCount} errors across {result.FieldsWithErrorsCount} fields");
foreach (var error in result.Errors)
Console.WriteLine($"{error.Field}: {error.Message}");
}
// Validate and throw on failure
await Validate.AllAndThrowAsync([
Guard.For(username, "Username").NotEmpty().Alphanumeric(),
Guard.For(password, "Password").NotEmpty().MinimumLength(8)
]);
Fluent Builder API
Use the fluent builder for more readable multi-validation scenarios:
var result = await Validate.All()
.Add(Guard.For(email, "Email").NotEmpty().Email())
.Add(Guard.For(age, "Age").GreaterThan(0).LessThan(150))
.Add(Guard.For(name, "Name").NotEmpty().MinimumLength(2))
.ValidateAsync();
// Or using extension methods for common scenarios
var result = await Validate.All()
.ValidateEmail(email)
.ValidateRange(age, "Age", 0, 150)
.ValidateRequired(name, "Name")
.ValidateString(password, "Password", p => p
.NotEmpty()
.MinimumLength(8)
.Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*$")
.WithMessage("Password must contain uppercase, lowercase and digit"))
.ValidateAsync();
// Fluent with exception throwing
await Validate.All()
.ValidateEmail(userEmail)
.ValidateRange(userAge, "Age", 18, 65)
.ValidateAndThrowAsync();
Array Extension Methods
Convenient extension methods for collections of validations:
var validations = new[] {
Guard.For("test@example.com", "Email").Email(),
Guard.For(25, "Age").GreaterThan(0),
Guard.For("John Doe", "Name").NotEmpty()
};
// Validate all with result
var result = await validations.ValidateAllAsync();
// Validate all and throw on failure
await validations.ValidateAllAndThrowAsync();
Multi-Validation with Async Rules
Combine parallel execution with async service validation:
var result = await Validate.All()
.ValidateValue(email, "Email", e => e
.NotEmpty()
.Email()
.RespectAsync(async (email, ct, sp) => {
var userService = sp.GetRequiredService<IUserService>();
return await userService.IsEmailAvailableAsync(email, ct);
})
.WithMessage("Email already exists"))
.ValidateValue(username, "Username", u => u
.NotEmpty()
.RespectAsync(async (user, ct, sp) => {
var userService = sp.GetRequiredService<IUserService>();
return await userService.IsUsernameAvailableAsync(user, ct);
})
.WithMessage("Username already taken"))
.ValidateAsync();
Complex Multi-Validation Scenarios
Handle complex validation scenarios with multiple fields and business rules:
// User registration validation
var result = await Validate.All()
.ValidateRequired(firstName, "FirstName")
.ValidateRequired(lastName, "LastName")
.ValidateEmail(email)
.ValidateString(password, "Password", p => p
.NotEmpty()
.MinimumLength(8)
.Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]")
.WithMessage("Password must contain uppercase, lowercase, number and special character"))
.ValidateRange(age, "Age", 18, 120)
.ValidateString(phone, "Phone", p => p
.NotEmpty()
.Matches(@"^\+?[1-9]\d{1,14}$")
.WithMessage("Please enter a valid phone number"))
.ValidateAsync();
if (!result.IsValid)
{
// Group errors by field for better UX
foreach (var fieldErrors in result.ErrorsByField)
{
Console.WriteLine($"{fieldErrors.Key}: {string.Join(", ", fieldErrors.Value.Select(e => e.Message))}");
}
}
MultiValidationResult Features
The MultiValidationResult provides rich functionality for working with aggregated validation results:
var result = await Validate.AllAsync(validations);
// Basic validation status
Console.WriteLine($"Is Valid: {result.IsValid}");
Console.WriteLine($"Total Errors: {result.ErrorCount}");
Console.WriteLine($"Fields with Errors: {result.FieldsWithErrorsCount}");
// Access all error messages
Console.WriteLine($"All Errors: {result.ErrorMessage}");
// Check specific fields
if (result.HasErrorsForField("Email"))
{
var emailErrors = result.GetErrorsForField("Email");
Console.WriteLine($"Email has {emailErrors.Count} errors");
}
// Group errors by field
foreach (var field in result.ErrorsByField)
{
Console.WriteLine($"{field.Key}: {field.Value.Count} errors");
foreach (var error in field.Value)
Console.WriteLine($" - {error.Message} ({error.Code})");
}
// Get first error for quick feedback
var firstError = result.FirstError;
if (firstError != null)
Console.WriteLine($"First error: {firstError.Field} - {firstError.Message}");
Standalone Validation with Guard.For()
Use Guard.For() for standalone validation outside of model contexts:
// Simple field validation
var emailResult = await Guard.For(email, "Email")
.NotEmpty()
.Email()
.ValidateAsync();
if (!emailResult.IsValid)
Console.WriteLine($"Email error: {emailResult.FirstError?.Message}");
// Validate and throw
await Guard.For(age, "Age")
.GreaterThan(0)
.LessThan(150)
.ValidateAndThrowAsync();
// Async validation with services
var usernameResult = await Guard.For(username, "Username")
.NotEmpty()
.MinimumLength(3)
.RespectAsync(async (user, ct, sp) => {
var userService = sp.GetService<IUserService>();
return await userService?.IsUsernameAvailableAsync(user, ct) ?? true;
})
.ValidateAsync(serviceProvider);
Dictionary Validation
Validate dictionaries with comprehensive rules for keys, values, and counts:
// Standalone dictionary validation
var headers = new Dictionary<string, string> { ["Authorization"] = "Bearer token" };
await Sentry.For(headers, "Headers")
.NotEmpty()
.ContainsKey("Authorization")
.AllKeys(k => !string.IsNullOrEmpty(k))
.AllValues(v => v.Length > 0)
.ValidateAndThrowAsync();
// Entity dictionary validation
public class ApiRequest : IValidatable<ApiRequest>
{
public Dictionary<string, string> Headers { get; set; }
public Dictionary<string, object> Metadata { get; set; }
public void Validate(ValidationBuilder<ApiRequest> builder, ValidationContextKey? context = null)
{
builder.For(Headers, r => r
.NotEmpty()
.ContainsKey("Authorization")
.CountBetween(1, 20)
.NoKeys(k => k.Contains("Debug")));
builder.For(Metadata, r => r
.CountLessThan(100)
.AllValues(v => v != null));
}
}
Available dictionary validation rules:
NotEmpty()- Dictionary must have at least one entryCountGreaterThan(min)- Entry count must exceed minimumCountLessThan(max)- Entry count must be below maximumCountBetween(min, max)- Entry count must be within rangeContainsKey(key)- Specific key must existNotContainsKey(key)- Specific key must not existContainsValue(value)- Specific value must existAllKeys(predicate)- All keys must satisfy conditionAllValues(predicate)- All values must satisfy conditionAnyKey(predicate)- At least one key must satisfy conditionAnyValue(predicate)- At least one value must satisfy conditionNoKeys(predicate)- No keys should satisfy conditionNoValues(predicate)- No values should satisfy condition
Manual Validation Failure
Use Sentry.Fail() to manually throw validation exceptions with custom error messages:
// Simple failure with default field "Value"
if (complexCondition)
{
Sentry.Fail("Complex business rule violated");
}
// Failure for specific field
if (user.Age < 18 && user.RequiresParentalConsent)
{
Sentry.Fail("Age", "User must be 18 or older or have parental consent");
}
// Failure with custom status code
if (email.Domain == "competitor.com")
{
Sentry.Fail("Email", "Email domain not allowed", HttpStatusCode.Forbidden);
}
// Failure with full validation error control
var error = new ValidationError(
"Email",
"Email already exists in the system",
HttpStatusCode.Conflict,
new[] { "user@example.com", "admin@example.com" }
);
Sentry.Fail(error);
// Multiple failures at once
var errors = new List<ValidationError>
{
new ValidationError("Name", "Name is required", HttpStatusCode.BadRequest),
new ValidationError("Email", "Email is invalid", HttpStatusCode.BadRequest)
};
Sentry.Fail(errors);
This is useful for:
- Complex business rules that don't fit standard validation patterns
- Cross-field validations
- Dynamic validation based on external state
- Integration with legacy validation code
Performance Benefits
Multi-validation provides significant performance benefits:
- Parallel Execution: All validations run simultaneously using
Task.WhenAll - Single Service Provider Resolution: DI services resolved once and shared
- Batched Error Collection: All errors collected in single pass
- Reduced Memory Allocation: Optimized for multiple validation scenarios
// Instead of multiple sequential calls (slower)
var emailResult = await Guard.For(email, "Email").Email().ValidateAsync();
var ageResult = await Guard.For(age, "Age").GreaterThan(0).ValidateAsync();
var nameResult = await Guard.For(name, "Name").NotEmpty().ValidateAsync();
// Use parallel multi-validation (faster)
var result = await Validate.AllAsync([
Guard.For(email, "Email").Email(),
Guard.For(age, "Age").GreaterThan(0),
Guard.For(name, "Name").NotEmpty()
]);
Global Exception Handling
Myth.Guard now includes a powerful Global Exception Handler that allows you to map any exception type to custom HTTP responses with appropriate status codes and error formats.
Opt-In Behavior
By default, UseGuard() only handles ValidationException automatically. Other exceptions are not intercepted unless you explicitly configure handlers for them. This ensures backward compatibility and gives you full control.
Quick Setup
Configure exception mappings when adding Guard services:
builder.Services.AddGuard( options => {
options.AutoMapCommonExceptions( );
} );
Important: Without calling AutoMapCommonExceptions() or configuring custom handlers, only ValidationException will be handled by the middleware.
The AutoMapCommonExceptions() method automatically configures sensible defaults for common .NET exceptions:
ArgumentNullException→ 400 Bad RequestArgumentException→ 400 Bad RequestInvalidOperationException→ 409 ConflictUnauthorizedAccessException→ 403 ForbiddenNotImplementedException→ 501 Not ImplementedTimeoutException→ 408 Request Timeout- Default handler → 500 Internal Server Error (with formatted stack trace in development)
Custom Exception Mappings
Map your own exception types with fluent configuration:
builder.Services.AddGuard( options => {
// Map specific exception types
options
.MapException<NotFoundException>( )
.WithStatusCode( 404 )
.WithErrorCode( "NOT_FOUND" )
.WithResponse( ex => new {
error = ex.Message,
resourceType = ex.ResourceType
} );
options
.MapException<BusinessRuleException>( )
.WithStatusCode( 422 )
.WithErrorCode( "BUSINESS_RULE_VIOLATION" )
.WithResponse( ex => new {
error = ex.Message,
rule = ex.RuleName,
details = ex.Details
} )
.OnBeforeResponse( ( ex, ctx ) => {
_logger.LogWarning( ex, "Business rule violation: {Rule}", ex.RuleName );
} );
// Configure default handler for unmapped exceptions
options
.MapDefaultException( )
.WithStatusCode( 500 )
.WithErrorCode( "INTERNAL_ERROR" )
.WithResponse( ex => new {
error = _env.IsDevelopment( ) ? ex.Message : "An internal error occurred",
trace = _env.IsDevelopment( ) ? ex.StackTrace : null
} )
.OnBeforeResponse( ( ex, ctx ) => {
_logger.LogError( ex, "Unhandled exception" );
} );
} );
API Reference
MapException<TException>()
Creates a mapping for a specific exception type.
Chainable Methods:
.WithStatusCode( int statusCode )- Sets HTTP status code (e.g., 404, 500).WithStatusCode( HttpStatusCode statusCode )- Sets HTTP status code using enum (e.g., HttpStatusCode.NotFound).WithStatusCode( Func<TException, int> resolver )- Dynamic status code resolver.WithStatusCode( Func<TException, HttpStatusCode> resolver )- Dynamic status code resolver with enum.WithErrorCode( string code )- Sets error code string.WithErrorCode( Func<TException, string> resolver )- Dynamic error code resolver.WithResponse( Func<TException, object> builder )- Builds response object.OnBeforeResponse( Action<TException, HttpContext> callback )- Executes before writing response (for logging, telemetry, etc.)
MapDefaultException()
Configures the fallback handler for unmapped exceptions. Uses the same fluent API as MapException<TException>().
AutoMapCommonExceptions( bool includeStackTrace = true )
Automatically configures handlers for common .NET exceptions with sensible defaults. In development mode, includes formatted stack traces for the default handler.
Exception Resolution
The middleware uses inheritance-aware resolution to find the best matching handler:
- Exact match: Looks for handler registered for the exact exception type
- Inheritance match: Searches for handlers of base types, prioritizing the most specific match
- Default handler: Falls back to the default handler if no match found
- Built-in fallback: Returns generic 500 error if no handlers configured
Stack Trace Formatting
When AutoMapCommonExceptions() is used with stack traces enabled (default in development), stack traces are automatically formatted for readability:
Before:
at MyApp.Services.UserService.GetUser(Int32 id) in C:\Projects\MyApp\Services\UserService.cs:line 42
at MyApp.Controllers.UserController.Get(Int32 id) in C:\Projects\MyApp\Controllers\UserController.cs:line 28
After:
at MyApp.Services.UserService.GetUser(Int32 id) in C:\Projects\MyApp\Services\UserService.cs:line 42
at MyApp.Controllers.UserController.Get(Int32 id) in C:\Projects\MyApp\Controllers\UserController.cs:line 28
Complete Example
// Program.cs
using System.Net;
var builder = WebApplication.CreateBuilder( args );
builder.Services.AddGuard( options => {
// Auto-map common exceptions
options.AutoMapCommonExceptions( );
// Custom domain exceptions using enum
options
.MapException<EntityNotFoundException>( )
.WithStatusCode( HttpStatusCode.NotFound )
.WithErrorCode( "ENTITY_NOT_FOUND" )
.WithResponse( ex => new {
error = $"{ex.EntityType} with ID {ex.EntityId} not found"
} );
// Or using int status code
options
.MapException<DuplicateEntityException>( )
.WithStatusCode( 409 )
.WithErrorCode( "DUPLICATE_ENTITY" )
.WithResponse( ex => new {
error = ex.Message,
conflictingField = ex.FieldName,
existingId = ex.ExistingEntityId
} );
// Dynamic status code using enum
options
.MapException<BusinessRuleException>( )
.WithStatusCode( ex => ex.IsCritical ? HttpStatusCode.Forbidden : HttpStatusCode.UnprocessableEntity )
.WithErrorCode( "BUSINESS_RULE_VIOLATION" )
.WithResponse( ex => new {
error = ex.Message,
rule = ex.RuleName
} );
} );
var app = builder.Build( );
// Enable global exception handling
app.UseGuard( );
app.MapControllers( );
app.Run( );
// Controller usage - no try/catch needed!
[ApiController]
[Route( "api/[controller]" )]
public class UsersController : ControllerBase {
[HttpGet( "{id}" )]
public async Task<UserDto> GetUser( int id ) {
// Throws EntityNotFoundException if not found
// Automatically handled by Guard middleware
return await _userService.GetByIdAsync( id );
}
}
Backward Compatibility
ValidationException continues to work exactly as before. The middleware automatically detects and handles it with the existing structured error format:
{
"code": "MULTIPLE_ERRORS",
"errors": [
{
"field": "email",
"message": "Email is required",
"code": "VIOLATION"
}
]
}
No changes required to existing validation code!
Testing
The validation design makes testing straightforward:
[Fact]
public async Task CreateUser_WithInvalidEmail_ShouldFail()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<IUserService>( sp => mockUserService.Object );
services.AddGuard();
var serviceProvider = services.BuildServiceProvider();
var validator = serviceProvider.GetRequiredService<IValidator>();
var dto = new CreateUserDto
{
Name = "John Doe",
Email = "invalid-email",
Age = 25
};
// Act & Assert
var exception = await Assert.ThrowsAsync<ValidationException>(
() => validator.ValidateAsync( dto, ValidationContextKey.Create ) );
exception.ValidationResult.Errors.Should().HaveCount( 1 );
exception.ValidationResult.Errors.First().Field.Should().Be( "Email" );
exception.ValidationResult.Errors.First().Code.Should().Be( "VIOLATION" );
}
[Fact]
public async Task CreateUser_WithExistingEmail_ShouldReturnConflict()
{
// Arrange
mockUserService.Setup( x => x.IsEmailAvailableAsync( "existing@test.com", It.IsAny<CancellationToken>() ) )
.ReturnsAsync( false );
var dto = new CreateUserDto
{
Name = "John Doe",
Email = "existing@test.com",
Age = 25
};
// Act
var result = await validator.ValidateAndReturnAsync( dto, ValidationContextKey.Create );
// Assert
result.IsValid.Should().BeFalse();
result.StatusCode.Should().Be( HttpStatusCode.Conflict );
result.Errors.Should().ContainSingle( e => e.Code == "EMAIL_EXISTS" );
}
Best Practices
- Use Context-Aware Validation: Leverage
ValidationContextKeyfor operation-specific rules - Keep Validation Close to Entities: Implement
IValidatable<T>on DTOs for better maintainability - Add Middleware: Use
app.UseGuard()for automatic exception handling - Async Rules Sparingly: Only for database/API checks requiring external services
- Meaningful Error Messages: Provide clear, actionable messages for users
- Use Custom Status Codes: Set appropriate HTTP codes for different validation failures
- Stop on Critical Failures: Use
SetStopOnFailure()for rules preventing further validation - Test Validation Logic: Test both positive and negative scenarios
- Separate Concerns: Keep validation focused, avoid business logic in validators
- DDD Integration: Use validation as part of your domain model's invariants
Architecture Patterns
Repository Pattern Integration
public class UserRepository
{
private readonly IValidator _validator;
private readonly IDbContext _context;
public UserRepository( IValidator validator, IDbContext context )
{
_validator = validator;
_context = context;
}
public async Task<User> CreateAsync( CreateUserDto dto )
{
await _validator.ValidateAsync( dto, ValidationContextKey.Create );
var user = new User
{
Name = dto.Name,
Email = dto.Email,
Age = dto.Age
};
_context.Users.Add( user );
await _context.SaveChangesAsync();
return user;
}
public async Task<User> UpdateAsync( int id, UpdateUserDto dto )
{
var user = await _context.Users.FindAsync( id );
if ( user == null )
throw new NotFoundException( "User not found" );
await _validator.ValidateAsync( dto, ValidationContextKey.Update );
user.Name = dto.Name;
user.Age = dto.Age;
await _context.SaveChangesAsync();
return user;
}
}
CQRS Command Validation
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
private readonly IValidator _validator;
private readonly IOrderRepository _repository;
public CreateOrderCommandHandler( IValidator validator, IOrderRepository repository )
{
_validator = validator;
_repository = repository;
}
public async Task<CommandResult> HandleAsync( CreateOrderCommand command )
{
try
{
await _validator.ValidateAsync( command.OrderData, ValidationContextKey.Create );
var order = await _repository.CreateAsync( command.OrderData );
return CommandResult.Success();
}
catch ( ValidationException ex )
{
return CommandResult.Failure( ex.ValidationResult.Errors );
}
}
}
Performance Considerations
- Async Rules: Use
RespectAsync()only when necessary (database/API calls) - Stop on Failure: Use
SetStopOnFailure()for expensive validation rules - Context Filtering: Use specific contexts to avoid unnecessary rule execution
- Service Caching: Cache expensive service calls in async validation rules
- Reflection Overhead: Minimal performance impact for typical use cases
License
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Support
For issues, questions, or contributions, please visit the GitLab repository.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Myth.Commons (>= 4.4.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.4.3 | 152 | 6/9/2026 |
| 4.4.2 | 126 | 6/7/2026 |
| 4.4.1 | 118 | 6/5/2026 |
| 4.4.0 | 115 | 6/5/2026 |
| 4.4.0-preview.12 | 72 | 6/4/2026 |
| 4.4.0-preview.11 | 69 | 6/4/2026 |
| 4.4.0-preview.10 | 77 | 6/2/2026 |
| 4.4.0-preview.9 | 66 | 6/2/2026 |
| 4.4.0-preview.8 | 110 | 3/12/2026 |
| 4.4.0-preview.7 | 75 | 3/12/2026 |
| 4.4.0-preview.6 | 89 | 2/20/2026 |
| 4.4.0-preview.5 | 88 | 2/19/2026 |
| 4.4.0-preview.4 | 74 | 2/18/2026 |
| 4.4.0-preview.3 | 82 | 2/18/2026 |
| 4.4.0-preview.2 | 79 | 2/17/2026 |
| 4.4.0-preview.1 | 82 | 2/14/2026 |
| 4.3.0 | 141 | 2/1/2026 |
| 4.3.0-preview.3 | 89 | 2/1/2026 |
| 4.3.0-preview.2 | 170 | 12/22/2025 |
| 4.2.1-preview.1 | 659 | 12/2/2025 |