-
Notifications
You must be signed in to change notification settings - Fork 1
Automatic Value Object Validation #262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Add infrastructure for automatic validation of value objects in ASP.NET Core using ITryCreatable<T> and System.Text.Json. Introduce converters, middleware, filters, and extension methods to collect and return all validation errors in standard ASP.NET Core responses. Update documentation and tests to demonstrate and verify the new pattern, enabling type-safe, boilerplate-free validation in controllers and minimal APIs.
Introduce ValueObjectValidationEndpointFilter and WithValueObjectValidation() for automatic value object validation in Minimal APIs. Endpoints now return 400 Bad Request with problem details if DTO deserialization fails. Updated README, Program.cs, and UserRoutes.cs with Minimal API setup and usage examples. Added tests for the new filter and expanded JSON serializer context for validation responses. Improved documentation for both MVC and Minimal API scenarios.
Added detailed HTTP request examples to SampleMinimalApi.http for the /users/registerWithValidation endpoint. Scenarios cover valid registration, value object validation failures (empty/invalid fields), and password validation errors. Includes comments and formatting improvements for clarity.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #262 +/- ##
==========================================
+ Coverage 89.65% 90.00% +0.35%
==========================================
Files 72 81 +9
Lines 1875 2111 +236
Branches 373 429 +56
==========================================
+ Hits 1681 1900 +219
- Misses 122 131 +9
- Partials 72 80 +8 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Introduce Asp.Generator to discover ITryCreatable<T> types at compile time and auto-register their JSON converters for AOT scenarios. Add ValidatingConverterRegistry for fast, reflection-free lookup. Update ValidatingJsonConverterFactory to use the registry when available, falling back to reflection otherwise. Improves System.Text.Json value object validation for AOT and trimming.
- Add tests for ValidatingJsonConverter, including edge cases and struct support - Introduce TestStructValueObject for struct converter scenarios - Add ValidatingConverterRegistryTests for registry and factory integration, including concurrency - Add ValueObjectValidationMiddleware and DI extension tests - Greatly improve coverage for class/struct value objects, registry, middleware, and DI registration
- Documented AOT compatibility and setup for FunctionalDDD.Asp.Generator in main and integration ASP.NET docs - Added detailed explanation of generator architecture, usage, and troubleshooting - Expanded value object validation docs with diagrams and error examples - Updated best practices and code comments for clarity and consistency - Removed outdated AOT limitation notes; clarified generator usage for Native AOT - Added new README for FunctionalDDD.Asp.Generator project with installation, architecture, and performance info
Remove obsolete test for non-ValidationError error collection and add ValidatingJsonConverterCoverageTests to cover additional cases, including NotFoundError/DomainError handling and GetDefaultFieldName edge cases. Add supporting test types. Bump .NET SDK to 10.0.102.
Add "Limitations" sections to README and integration-aspnet.md, clarifying that automatic validation only supports JSON content types and requires ITryCreatable<T>. Provide guidance and examples for manual validation with other formats.
Validation errors now reference actual DTO property names (not type names) during JSON deserialization. - Added PropertyNameAwareConverter<T> to set property name context for error reporting. - Updated ValidatingJsonConverter(s) to use property name from ValidationErrorsContext. - Extended ValidationErrorsContext to track property name via AsyncLocal. - Modified ValueObjectValidationExtensions to assign property-aware converters using TypeInfoResolver, supporting AOT. - Updated sample DTOs, API routes, and HTTP requests to demonstrate correct error field mapping. Improves error feedback accuracy for APIs and clients.
Introduce registry-based property-name-aware JSON converter wrappers to ensure validation errors are mapped to DTO property names, not type names. Add new DTOs, API route, and source generator entries for testing. Extensive tests verify correct error attribution, registry logic, and thread-safety. Update sample HTTP requests to demonstrate behavior.
Added three async tests to PropertyNameAwareConverterTests.cs to verify thread safety and error isolation in concurrent deserialization scenarios. Tests cover isolated error reporting, prevention of error leakage, and correct restoration of property names in parallel requests. Uses ConcurrentBag and Task.WhenAll to simulate real-world parallelism.
Added detailed docs and examples for property-name-aware validation, showing how error responses now use DTO property names instead of value object type names. Explained the AsyncLocal-based implementation, thread safety, AOT compatibility, and updated generator docs to reflect the new converter wrapper behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request introduces Automatic Value Object Validation for ASP.NET Core applications, eliminating the need for manual Combine chains by validating value objects directly during JSON deserialization. The feature includes an AOT-compatible source generator for Native AOT scenarios.
Changes:
- Automatic validation infrastructure using
ValidatingJsonConverterandValidationErrorsContext - Source generator (
Asp.Generator) for AOT-compatible converter registration - Middleware and filters for MVC/Minimal API integration with property-name-aware error reporting
- Comprehensive test coverage and documentation updates
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
global.json |
Updated SDK version to 10.0.102 |
docs/docfx_project/articles/integration-aspnet.md |
Added comprehensive documentation for automatic validation, property-aware errors, and AOT support |
Asp/src/Validation/*.cs |
Core validation infrastructure: middleware, filters, converters, registry |
Asp/generator/*.cs |
Source generator for AOT-compatible converter discovery and registration |
Asp/tests/Validation/*.cs |
Comprehensive test suite with 900+ lines of tests covering all scenarios |
Examples/* |
Updated sample applications demonstrating the new validation feature |
| foreach (var member in ns.GetMembers()) | ||
| { | ||
| if (member is INamespaceSymbol childNs) | ||
| { | ||
| ScanNamespace(childNs, iTryCreatableSymbol, types); | ||
| } | ||
| else if (member is INamedTypeSymbol typeSymbol) | ||
| { | ||
| if (ImplementsITryCreatable(typeSymbol, iTryCreatableSymbol)) | ||
| { | ||
| types.Add(new TypeInfo( | ||
| typeSymbol.ContainingNamespace.ToDisplayString(), | ||
| typeSymbol.Name, | ||
| typeSymbol.ToDisplayString(), | ||
| typeSymbol.IsValueType)); | ||
| } | ||
|
|
||
| // Check nested types | ||
| foreach (var nestedType in typeSymbol.GetTypeMembers()) | ||
| { | ||
| if (ImplementsITryCreatable(nestedType, iTryCreatableSymbol)) | ||
| { | ||
| types.Add(new TypeInfo( | ||
| nestedType.ContainingNamespace.ToDisplayString(), | ||
| nestedType.Name, | ||
| nestedType.ToDisplayString(), | ||
| nestedType.IsValueType)); | ||
| } | ||
| } | ||
| } |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var member in ns.GetMembers()) | |
| { | |
| if (member is INamespaceSymbol childNs) | |
| { | |
| ScanNamespace(childNs, iTryCreatableSymbol, types); | |
| } | |
| else if (member is INamedTypeSymbol typeSymbol) | |
| { | |
| if (ImplementsITryCreatable(typeSymbol, iTryCreatableSymbol)) | |
| { | |
| types.Add(new TypeInfo( | |
| typeSymbol.ContainingNamespace.ToDisplayString(), | |
| typeSymbol.Name, | |
| typeSymbol.ToDisplayString(), | |
| typeSymbol.IsValueType)); | |
| } | |
| // Check nested types | |
| foreach (var nestedType in typeSymbol.GetTypeMembers()) | |
| { | |
| if (ImplementsITryCreatable(nestedType, iTryCreatableSymbol)) | |
| { | |
| types.Add(new TypeInfo( | |
| nestedType.ContainingNamespace.ToDisplayString(), | |
| nestedType.Name, | |
| nestedType.ToDisplayString(), | |
| nestedType.IsValueType)); | |
| } | |
| } | |
| } | |
| foreach (var childNs in ns.GetMembers().OfType<INamespaceSymbol>()) | |
| ScanNamespace(childNs, iTryCreatableSymbol, types); | |
| foreach (var typeSymbol in ns.GetMembers() | |
| .OfType<INamedTypeSymbol>() | |
| .Where(type => ImplementsITryCreatable(type, iTryCreatableSymbol))) | |
| { | |
| types.Add(new TypeInfo( | |
| typeSymbol.ContainingNamespace.ToDisplayString(), | |
| typeSymbol.Name, | |
| typeSymbol.ToDisplayString(), | |
| typeSymbol.IsValueType)); | |
| // Check nested types | |
| foreach (var nestedType in typeSymbol.GetTypeMembers() | |
| .Where(nested => ImplementsITryCreatable(nested, iTryCreatableSymbol))) | |
| { | |
| types.Add(new TypeInfo( | |
| nestedType.ContainingNamespace.ToDisplayString(), | |
| nestedType.Name, | |
| nestedType.ToDisplayString(), | |
| nestedType.IsValueType)); | |
| } |
| foreach (var iface in typeSymbol.AllInterfaces) | ||
| { | ||
| if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, iTryCreatableSymbol)) | ||
| { | ||
| // Check if T is the type itself | ||
| if (iface.TypeArguments.Length == 1 && | ||
| SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], typeSymbol)) | ||
| { | ||
| return true; | ||
| } | ||
| } | ||
| } |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var detail in fieldError.Details) | ||
| { | ||
| if (!errors.Contains(detail)) | ||
| { | ||
| errors.Add(detail); | ||
| } | ||
| } |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| ValidationErrorsContext.AddError("outer", "Outer error"); | ||
|
|
||
| // Act | ||
| using (var innerScope = ValidationErrorsContext.BeginScope()) |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This assignment to innerScope is useless, since its value is never read.
| using (var innerScope = ValidationErrorsContext.BeginScope()) | |
| using (ValidationErrorsContext.BeginScope()) |
| if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, iTryCreatableSymbol)) | ||
| { | ||
| // Check if T is the type itself | ||
| if (iface.TypeArguments.Length == 1 && | ||
| SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], typeSymbol)) | ||
| { | ||
| return true; | ||
| } | ||
| } |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 'if' statements can be combined.
| if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, iTryCreatableSymbol)) | |
| { | |
| // Check if T is the type itself | |
| if (iface.TypeArguments.Length == 1 && | |
| SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], typeSymbol)) | |
| { | |
| return true; | |
| } | |
| } | |
| if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, iTryCreatableSymbol) && | |
| iface.TypeArguments.Length == 1 && | |
| SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], typeSymbol)) | |
| return true; |
| Type converterType; | ||
| if (actualType.IsValueType) | ||
| { | ||
| // Use the struct converter for value types | ||
| converterType = typeof(ValidatingStructJsonConverter<>).MakeGenericType(actualType); | ||
| } | ||
| else | ||
| { | ||
| // Use the class converter for reference types | ||
| converterType = typeof(ValidatingJsonConverter<>).MakeGenericType(actualType); | ||
| } |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.
| Type converterType; | |
| if (actualType.IsValueType) | |
| { | |
| // Use the struct converter for value types | |
| converterType = typeof(ValidatingStructJsonConverter<>).MakeGenericType(actualType); | |
| } | |
| else | |
| { | |
| // Use the class converter for reference types | |
| converterType = typeof(ValidatingJsonConverter<>).MakeGenericType(actualType); | |
| } | |
| // Use the struct converter for value types, class converter for reference types | |
| Type converterType = actualType.IsValueType | |
| ? typeof(ValidatingStructJsonConverter<>).MakeGenericType(actualType) | |
| : typeof(ValidatingJsonConverter<>).MakeGenericType(actualType); |
Expand test coverage for scenarios where the source generator is not used or types are unregistered, ensuring reflection fallback works for class, struct, and nullable struct value objects. Refactor ITryCreatable<T> check logic and clean up scope management in tests. Improves reliability and error handling of the validation/converter infrastructure.
Enhance PropertyNameAwareConverterFactory to correctly handle struct value objects by wrapping them in nullable converters during reflection fallback. Expand ValueObjectValidationExtensionsTests with integration tests covering DI configuration, property name error reporting, non-object and nullable types, and reflection fallback scenarios. Add helper DTOs for testing. Ensures robust validation and error reporting with or without source generators.
Introduce a new example project showing automatic value object validation in Minimal APIs using reflection (no Asp.Generator). Includes endpoints, DTOs, README, launch settings, and HTTP samples. Solution updated to include new project and generator. Documentation compares reflection vs. AOT generator approaches.
Eliminate manual
Combinechains by using value objects directly in your DTOs. Validation errors are automatically collected during JSON deserialization.