Comprehensive ASP.NET Core integration for functional domain-driven design, providing:
- Automatic Scalar Value Validation - Property-aware error messages with comprehensive error collection
- Result-to-HTTP Conversion - Seamless
Result<T>to HTTP response mapping - Model Binding - Automatic binding from route/query/form/headers
- Optional Value Objects -
Maybe<T>support for JSON, model binding, and MVC validation - Native AOT Support - Optional source generator for zero-reflection overhead
- Installation
- Scalar Value Validation
- Quick Start
- Result Conversion
- Advanced Topics
- Best Practices
- Examples
- Related Packages
- License
dotnet add package Trellis.AspAutomatically validate types implementing IScalarValue<TSelf, TPrimitive> during JSON deserialization and model binding with property-aware error messages.
Note: This includes DDD value objects (like
ScalarValueObject<T>) as well as any custom implementations ofIScalarValue.
1. Define Value Objects
Value objects that implement IScalarValue get automatic validation:
public class EmailAddress : ScalarValueObject<EmailAddress, string>,
IScalarValue<EmailAddress, string>
{
private EmailAddress(string value) : base(value) { }
public static Result<EmailAddress> TryCreate(string? value, string? fieldName = null)
{
var field = fieldName ?? "email";
if (string.IsNullOrWhiteSpace(value))
return Error.Validation("Email is required.", field);
if (!value.Contains('@'))
return Error.Validation("Email must contain @.", field);
return new EmailAddress(value);
}
}2. Use in DTOs
public record RegisterUserDto
{
public EmailAddress Email { get; init; } = null!;
public FirstName FirstName { get; init; } = null!;
public string Password { get; init; } = null!;
}3. Setup Validation
var builder = WebApplication.CreateBuilder(args);
// For MVC Controllers
builder.Services
.AddControllers()
.AddScalarValueValidation();
// For Minimal APIs
builder.Services.AddScalarValueValidationForMinimalApi();
var app = builder.Build();
app.UseScalarValueValidation(); // Required middleware
app.Run();4. Automatic Validation
[HttpPost]
public IActionResult Register(RegisterUserDto dto)
{
// If we reach here, dto is fully validated!
return Ok(User.Create(dto.Email, dto.FirstName, dto.Password));
}Request:
{
"email": "invalid",
"firstName": "",
"password": "test"
}Response (400 Bad Request):
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["Email must contain @."],
"FirstName": ["Name cannot be empty."]
}
}Full integration with MVC model binding and validation:
builder.Services
.AddControllers()
.AddScalarValueValidation(); // Adds JSON validation + model binding
var app = builder.Build();
app.UseScalarValueValidation(); // Middleware
app.MapControllers();
app.Run();Features:
- JSON deserialization with validation
- Model binding from route/query/form/headers
- Automatic 400 responses via
ScalarValueValidationFilter - Integrates with
[ApiController]attribute
Endpoint-specific validation with filters:
builder.Services.AddScalarValueValidationForMinimalApi();
var app = builder.Build();
app.UseScalarValueValidation();
app.MapPost("/users", (RegisterUserDto dto) => ...)
.WithScalarValueValidation(); // Add filter to each endpoint
app.Run();Features:
- JSON deserialization with validation
- Endpoint filter for automatic 400 responses
- No automatic model binding (use JSON body)
Value objects automatically bind from various sources in MVC:
// Route parameters
[HttpGet("{userId}")]
public IActionResult GetUser(UserId userId) => Ok(user);
// Query parameters
[HttpGet]
public IActionResult Search([FromQuery] EmailAddress email) => Ok(results);
// Form data
[HttpPost]
public IActionResult Login([FromForm] EmailAddress email, [FromForm] string password) => Ok();
// Headers
[HttpGet]
public IActionResult GetProfile([FromHeader(Name = "X-User-Id")] UserId userId) => Ok();For Native AOT applications, add the source generator:
1. Add Generator Reference
<ItemGroup>
<ProjectReference Include="path/to/AspSourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>2. Mark Your JsonSerializerContext
[GenerateScalarValueConverters] // ← Add this
[JsonSerializable(typeof(RegisterUserDto))]
[JsonSerializable(typeof(User))]
public partial class AppJsonSerializerContext : JsonSerializerContext { }3. Configure
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default));The generator automatically:
- Detects all types implementing
IScalarValue - Generates AOT-compatible converters
- Adds
[JsonSerializable]attributes - Enables Native AOT with
<PublishAot>true</PublishAot>
Note: The source generator is optional. Without it, the library uses reflection (works for standard .NET). See docs/REFLECTION-FALLBACK.md for details.
Use Maybe<T> for optional value object properties in DTOs. No additional setup is needed — AddScalarValueValidation() automatically registers the JSON converter, model binder, and validation suppression for Maybe<T> properties.
| JSON Value | Result |
|---|---|
null or absent |
Maybe.None (no error) |
| Valid value | Maybe.From(validated) |
| Invalid value | Validation error collected |
public record RegisterUserDto
{
public FirstName FirstName { get; init; } = null!; // Required
public EmailAddress Email { get; init; } = null!; // Required
public Maybe<Url> Website { get; init; } // Optional
}Request with optional value:
{ "firstName": "Jane", "email": "jane@example.com", "website": "https://jane.dev" }→ Website = Maybe.From(Url)
Request without optional value:
{ "firstName": "Jane", "email": "jane@example.com", "website": null }→ Website = Maybe.None
Request with invalid optional value:
{ "firstName": "Jane", "email": "jane@example.com", "website": "not-a-url" }→ 400 with "Website": ["Url is not valid."]
Maybe<T> properties bind from route, query, form, and header sources:
| Source Value | Result |
|---|---|
| Absent / empty | Maybe.None (success) |
| Valid | Maybe.From(result) |
| Invalid | ModelState error |
[HttpPut("{orderId}")]
public IActionResult Update(OrderId orderId, [FromBody] UpdateOrderDto dto)
{
// dto.AssignedTo is Maybe<FirstName>
// Absent → Maybe.None, valid → Maybe.From(name), invalid → 400
return Ok();
}MaybeSuppressChildValidationMetadataProvider prevents MVC from requiring child properties on Maybe<T> structs. This is registered automatically by AddScalarValueValidation().
Without it, MVC's HasValidators check would throw a NullReferenceException when encountering a Maybe<T> with no value, because Maybe<T> is a struct whose Value property returns default when HasNoValue is true.
Convert Railway Oriented Programming Result<T> types to HTTP responses.
Use ToActionResult to convert Result<T> to ActionResult<T>:
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpPost]
public ActionResult<User> Register([FromBody] RegisterRequest request) =>
FirstName.TryCreate(request.FirstName)
.Combine(LastName.TryCreate(request.LastName))
.Combine(EmailAddress.TryCreate(request.Email))
.Bind((firstName, lastName, email) =>
User.TryCreate(firstName, lastName, email, request.Password))
.ToCreatedAtActionResult(this,
actionName: nameof(GetUserAsync),
routeValues: user => new { id = user.Id });
[HttpGet("{id}")]
public async Task<ActionResult<User>> GetUserAsync(
string id,
CancellationToken cancellationToken) =>
await _userRepository.GetByIdAsync(id, cancellationToken)
.ToResultAsync(Error.NotFound($"User {id} not found"))
.ToActionResultAsync(this);
}Use ToHttpResult to convert Result<T> to IResult:
var userApi = app.MapGroup("/api/users");
userApi.MapPost("/register", (RegisterUserRequest request) =>
FirstName.TryCreate(request.FirstName)
.Combine(LastName.TryCreate(request.LastName))
.Combine(EmailAddress.TryCreate(request.Email))
.Bind((firstName, lastName, email) =>
User.TryCreate(firstName, lastName, email, request.Password))
.ToCreatedAtRouteHttpResult(
routeName: "GetUser",
routeValues: user => new RouteValueDictionary(new { id = user.Id })));
userApi.MapGet("/{id}", async (
string id,
UserRepository repository,
CancellationToken cancellationToken) =>
await repository.GetByIdAsync(id, cancellationToken)
.ToResultAsync(Error.NotFound($"User {id} not found"))
.ToHttpResultAsync())
.WithName("GetUser");| Result Type | HTTP Status | Description |
|---|---|---|
| Success | 200 OK | Success with content |
| Success (Created) | 201 Created | Resource created with Location header |
| Success (Unit) | 204 No Content | Success without content |
| ValidationError | 400 Bad Request | Validation errors with details |
| BadRequestError | 400 Bad Request | Invalid request |
| UnauthorizedError | 401 Unauthorized | Authentication required |
| ForbiddenError | 403 Forbidden | Access denied |
| NotFoundError | 404 Not Found | Resource not found |
| ConflictError | 409 Conflict | Resource conflict |
| DomainError | 422 Unprocessable Entity | Domain rule violation |
| RateLimitError | 429 Too Many Requests | Rate limit exceeded |
| UnexpectedError | 500 Internal Server Error | Unexpected error |
| ServiceUnavailableError | 503 Service Unavailable | Service unavailable |
When the same value object type is used for multiple properties:
public record PersonDto
{
public Name FirstName { get; init; } // ← Same type
public Name LastName { get; init; } // ← Same type
}Errors correctly show property names:
{
"errors": {
"FirstName": ["Name cannot be empty."],
"LastName": ["Name cannot be empty."]
}
}Not type names! This requires the fieldName parameter in TryCreate:
public static Result<Name> TryCreate(string? value, string? fieldName = null)
{
var field = fieldName ?? "name"; // ← Use fieldName
if (string.IsNullOrWhiteSpace(value))
return Error.Validation("Name cannot be empty.", field);
return new Name(value);
}You can use both automatic validation and manual Result chaining:
[HttpPost]
public ActionResult<User> Register(RegisterUserDto dto)
{
// dto.Email and dto.FirstName are already validated!
// Now validate business rules:
return UserService.CheckEmailNotTaken(dto.Email)
.Bind(() => User.TryCreate(dto.Email, dto.FirstName, dto.Password))
.ToActionResult(this);
}This combines:
- Automatic validation - DTO properties validated on deserialization
- Manual validation - Business rules in domain layer
For Minimal APIs, customize the response:
app.MapPost("/users", (RegisterUserDto dto, HttpContext httpContext) =>
{
var validationError = ValidationErrorsContext.GetValidationError();
if (validationError is not null)
{
return Results.Json(
new { success = false, errors = validationError.ToDictionary() },
statusCode: 422); // Custom status
}
var user = userService.Create(dto);
return Results.Ok(user);
});| Feature | Reflection | Source Generator |
|---|---|---|
| Setup | Simple (no generator) | Requires analyzer reference |
| Performance | ~50μs overhead at startup | Zero overhead |
| AOT Support | No | Yes |
| Trimming | May break | Safe |
| Use Case | Prototyping, standard .NET | Production, Native AOT |
See docs/REFLECTION-FALLBACK.md for comprehensive comparison.
- Always use
fieldNameparameter - Enables property-aware errors - Call validation setup in
Program.cs- Required for automatic validation - Add
UseScalarValueValidation()middleware - Creates validation scope - Use
[ApiController]in MVC - Enables automatic validation responses
- Always pass
thistoToActionResult- Required for HTTP context - Use async variants for async operations -
ToActionResultAsync,ToHttpResultAsync - Always provide CancellationToken - Enables proper cancellation
- Use domain-specific errors -
Error.NotFound(), not exceptions - Keep domain logic in domain layer - Controllers orchestrate, not implement
- Combine approaches wisely - Automatic validation for DTOs, manual for business rules
- Use source generator for production - Better performance, AOT support
- Test validation thoroughly - Unit test value objects, integration test endpoints
- SampleMinimalApi — Minimal API with Native AOT and source generator
- SampleMinimalApiNoAot — Minimal API with reflection fallback (no source generator) ← Start here!
- SampleWebApplication — MVC controllers with validation
- SampleUserLibrary — Shared value objects
- Trellis.Results — Core
Result<T>type - Trellis.Primitives — Base value object types
- Trellis.DomainDrivenDesign — Entity and aggregate patterns
- Trellis.AspSourceGenerator — AOT source generator
MIT — see LICENSE for details.