The Trellis.Asp library provides automatic fallback to reflection when the source generator is not available. This means you can use value object validation without any source generator reference in standard .NET applications.
When to use:
- Building Native AOT applications (
<PublishAot>true</PublishAot>) - Need assembly trimming
- Want zero reflection overhead
- Require fastest possible startup
Setup:
<ItemGroup>
<ProjectReference Include="..\..\Asp\generator\AspSourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>[GenerateScalarValueConverters]
[JsonSerializable(typeof(MyDto))]
public partial class AppJsonSerializerContext : JsonSerializerContext
{
}How it works:
- Source generator runs at compile time
- Generates strongly-typed JSON converters
- Adds
[JsonSerializable]attributes automatically - Zero reflection, fully AOT-compatible
When to use:
- Standard .NET applications (not Native AOT)
- Rapid prototyping
- Don't want to manage source generator references
- Reflection overhead is acceptable
Setup:
// For MVC Controllers
builder.Services
.AddControllers()
.AddScalarValueValidation();
// For Minimal APIs
builder.Services.AddScalarValueValidationForMinimalApi();
// That's it! No source generator needed.How it works:
ValidatingJsonConverterFactoryuses reflection at runtime- Detects types implementing
IScalarValue<TSelf, TPrimitive> - Creates converters dynamically using
Activator.CreateInstance - Transparent - your application code is identical
| Metric | Reflection Path | Source Generator Path |
|---|---|---|
| First request | ~50μs slower (one-time reflection cost) | Fastest (pre-compiled) |
| Subsequent requests | Same performance | Same performance |
| Memory at startup | Slightly higher (~1-2KB per type) | Lower |
| Startup time | Negligible difference (<1ms for 100 types) | Fastest |
| AOT compatible | ❌ NO | ✅ YES |
| Assembly trimming | ✅ Safe | |
| Build complexity | ✅ Simpler | Requires analyzer reference |
For most applications, the reflection overhead is negligible:
- Startup: The reflection scan happens once per type, typically <1ms for 100 value object types
- Runtime: After converters are created, performance is identical to source-generated converters
- Memory: Minimal - reflection metadata is shared across all instances
Example: An API with 50 value object types:
- Reflection overhead: ~0.5ms at startup
- Memory overhead: ~50KB
- Runtime performance: Identical to source generator
The library automatically uses reflection when:
- No
[GenerateScalarValueConverters]attribute is found on anyJsonSerializerContext - Source generator not referenced in the project
- Running on standard .NET runtime (not Native AOT)
// Program.cs
using Trellis;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllers()
.AddScalarValueValidation(); // ← Uses reflection automatically
var app = builder.Build();
app.MapControllers();
app.Run();// Value object - works with reflection!
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);
}
}// DTO - uses EmailAddress directly
public record RegisterUserDto(EmailAddress Email, string Password);
// Controller - automatic validation!
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult Register(RegisterUserDto dto)
{
// If we reach here, dto.Email is already validated!
// No manual TryCreate calls needed
return Ok(new { dto.Email.Value });
}
}Request:
POST /api/users
Content-Type: application/json
{
"email": "invalid",
"password": "secret"
}Response (400 Bad Request):
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["Email must contain @."]
}
}Perfect for prototyping and small applications:
builder.Services.AddScalarValueValidation();
// ← Uses reflection, works immediatelyWhen ready for production or Native AOT:
-
Add generator reference:
<ProjectReference Include="path/to/AspSourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
-
Add attribute to your
JsonSerializerContext:[GenerateScalarValueConverters] // ← Add this line [JsonSerializable(typeof(MyDto))] public partial class AppJsonSerializerContext : JsonSerializerContext { }
-
That's it! The source generator takes over automatically.
Your application code doesn't change at all - same DTOs, same controllers, same validation logic.
You can check at runtime which path is being used:
var options = app.Services.GetRequiredService<IOptions<JsonOptions>>().Value;
var hasGeneratedContext = options.SerializerOptions.TypeInfoResolver
is JsonSerializerContext context
&& context.GetType().GetCustomAttributes(typeof(GenerateScalarValueConvertersAttribute), false).Any();
if (hasGeneratedContext)
Console.WriteLine("Using source-generated converters (AOT-compatible)");
else
Console.WriteLine("Using reflection-based converters (fallback)");Check:
- Is
AddScalarValueValidation()orAddScalarValueValidationForMinimalApi()called? - Does your value object implement
IScalarValue<TSelf, TPrimitive>? - Is the
TryCreatemethod signature correct?
These warnings are expected when using reflection path. They indicate:
- The reflection factory cannot be trimmed
- Not compatible with Native AOT
Solutions:
- Suppress warnings if staying on standard .NET runtime
- Add source generator for AOT/trimming scenarios
Check:
- Is generator referenced with
OutputItemType="Analyzer"? - Does any
JsonSerializerContexthave[GenerateScalarValueConverters]? - Try
dotnet cleanand rebuild
| Scenario | Recommended Approach |
|---|---|
| Prototyping | Reflection (no generator) |
| Small-medium apps | Reflection (simpler setup) |
| Large apps | Source generator (better startup) |
| Native AOT | Source generator (required) |
| Assembly trimming | Source generator (required) |
| Maximum performance | Source generator (zero reflection) |
The beauty of this architecture is you choose what's best for your scenario - and you can change your mind later without touching application code.