This example demonstrates the new automatic validation feature for scalar value objects in ASP.NET Core.
Add one line to your Program.cs:
using Trellis;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllers()
.AddScalarValueValidation(); // ← Add this linepublic record RegisterUserRequest(
string firstName,
string lastName,
string email,
string password
);
[HttpPost("register")]
public ActionResult<User> Register([FromBody] 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))
.ToActionResult(this);Problems:
- Manual
TryCreate()calls for each field - Verbose
Combine()chaining - Error-prone - easy to forget a field
- No compile-time safety
public record RegisterUserDto
{
public FirstName FirstName { get; init; } = null!;
public LastName LastName { get; init; } = null!;
public EmailAddress Email { get; init; } = null!;
public string Password { get; init; } = null!;
}
[HttpPost("RegisterWithAutoValidation")]
public ActionResult<User> RegisterWithAutoValidation([FromBody] RegisterUserDto dto)
{
// If we reach here, all value objects are already validated!
// The [ApiController] attribute automatically returns 400 if validation fails.
Result<User> userResult = User.TryCreate(
dto.FirstName,
dto.LastName,
dto.Email,
dto.Password);
return userResult.ToActionResult(this);
}Benefits:
- ✅ No manual
TryCreate()calls - ✅ No
Combine()chaining - ✅ Validation happens automatically during model binding
- ✅ Compile-time safety - can't forget a field
- ✅ Clean, readable code
- ✅ Standard ASP.NET Core validation pipeline
- Model Binding: When a request comes in, ASP.NET Core uses the
ScalarValueObjectModelBinder - Automatic Validation: The binder calls
TryCreate()on each value object automatically - Error Collection: Validation errors are added to
ModelState - Automatic 400 Response: The
[ApiController]attribute returns 400 Bad Request ifModelStateis invalid - Your Controller: Only executed if all validations pass
See Register.http for example requests:
### Success - All validations pass
POST {{host}}/users/RegisterWithAutoValidation
Content-Type: application/json
{
"firstName": "Xavier",
"lastName": "John",
"email": "xavier@example.com",
"password": "SecurePass123!"
}
### Invalid Email - Returns 400 automatically
POST {{host}}/users/RegisterWithAutoValidation
Content-Type: application/json
{
"firstName": "Xavier",
"lastName": "John",
"email": "not-an-email",
"password": "SecurePass123!"
}{
"id": "550e8400-e29b-41d4-a716-446655440000",
"firstName": "Xavier",
"lastName": "John",
"email": "xavier@example.com"
}{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": [
"Email address must contain an @ symbol"
]
}
}{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"FirstName": [
"Value cannot be null or empty"
],
"Email": [
"Email address must contain an @ symbol"
]
}
}- Zero Reflection for TryCreate: The CRTP pattern enables direct interface calls
- Reflection Only for Discovery: Used only to detect which types need validation
- Opt-In: Only works when you add
.AddScalarValueValidation() - Works with Any Value Object: Supports
ScalarValueObject<TSelf, T>,RequiredString<TSelf>,RequiredGuid<TSelf>, etc. - Compatible with Existing Code: Old manual approach still works fine
The automatic validation uses:
- IScalarValue<TSelf, T> interface with static abstract
TryCreate(T)method - ScalarValueModelBinder that calls
TryCreate()during model binding - ScalarValueModelBinderProvider that detects scalar value types
- ValidatingJsonConverter for JSON serialization/deserialization
- CRTP (Curiously Recurring Template Pattern) for compile-time type safety
See the implementation plan for full technical details.