Skip to content
Purview Dev logo

Trimming, Native AOT and Blazor

Stable Value Objects Reviewed 2026-11-03 purview-dev/value-objects Star on GitHub 1 c-sharp csharp ddd domain-driven-design dotnet dto entity-framework immutable json roslyn scalar serialization single-case-union source-generator validation value-object value-objects zodsharp

The code the generator writes into your project - the factories, equality, comparison, parsing and formatting members, the JSON converters and the ZodSharp integration - is trim- and Native AOT-safe. It uses no reflection, so a trimmed or AOT-compiled application, a Blazor WebAssembly client among them, gets no IL2xxx/IL3xxx warning from it.

That is proven against the packed package, not the repository’s own projects: just smoke-packed-consumers packs Purview.ValueObjects, then publishes and runs a Native AOT console, a Blazor WebAssembly app (trimmed, and with RunAOTCompilation=true, loaded in a headless browser), a Blazor Server app and a net8.0 console against it, every one with warnings as errors, and scans every output for compiler-host assemblies.

Package Target frameworks
Purview.ValueObjects net8.0, net9.0, net10.0, net11.0
Purview.ZodSharp 3.x (optional) net10.0, net11.0
Purview.ZodSharp.Blazor 3.x (optional) net11.0

Purview.ValueObjects has no dependency on ZodSharp. A project that adds the ZodSharp integration needs the target frameworks ZodSharp supports; a project that does not keeps the full range.

The runtime assembly declares browser as a supported platform, so the platform compatibility analyzer (CA1416) reports any API it calls that is unsupported in Blazor WebAssembly.

ScalarJsonConverterFactory builds converters with reflection and expression trees, and is annotated [RequiresUnreferencedCode]/[RequiresDynamicCode], so registering it in a trimmed or AOT application warns at your call site. You do not need it for a generated value object: each one carries [JsonConverter] pointing at its generated converter.

The generated converters read and write through the JsonSerializerOptions they are called with, so naming policies, number handling and your own converters apply as usual. With a source-generated JsonSerializerContext there is one thing to know.

A context in the project that declares the value objects cannot see their [JsonConverter] attribute. The value object generator adds it, and one source generator never sees another’s output, so the context describes each value object as a plain object with a Value property. Register the generated converters on the options:

[JsonSerializable(typeof(Order))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
public sealed partial class AppJsonContext : JsonSerializerContext;
JsonSerializerOptions options = new(AppJsonContext.Default.Options)
{
Converters =
{
new EmailAddress.EmailAddressJsonConverter(),
new Money.MoneyJsonConverter(),
},
};
var json = JsonSerializer.Serialize(order, (JsonTypeInfo<Order>)options.GetTypeInfo(typeof(Order)));

A value object declared in a referenced library needs none of this: the attribute is compiled into the library, and the context in your application sees it.

Inside a generated converter:

  • a member of a complex value object that is itself a value object is written with that value object’s generated converter, so Money.Currency (a CurrencyCode) does not have to be registered or listed;
  • an underlying or member value of a primitive type (string, Guid, the numeric types, bool, the date and time types, Uri, Version, and their nullable forms) falls back to the built-in System.Text.Json converter when the context never listed it;
  • anything else - an enum, or a custom underlying type - needs metadata, so add [JsonSerializable(typeof(T))] to the context. The NotSupportedException thrown otherwise names the type.

A Native AOT minimal API with [Scalar] + [ZodSchema] value objects in Strict mode, built against ZodSharp 3.0.0-prerelease.2, raises no trim or AOT warning from any ZodSharp API it uses, under the build analyzers or ILC’s whole-program analysis:

Package Under trimming and Native AOT
Purview.ZodSharp Generated schemas use typed code, no reflection. Its reflection fallbacks sit behind the ZodSharp.Reflection.IsSupported feature switch, which is off when trimming or compiling ahead of time.
Purview.ZodSharp.AspNetCore AddZodSharpProblemDetails, ZodExceptionHandler and WithZodSharpValidation() are safe; the first adds ZodSharp’s ProblemDetails JSON metadata itself. Assembly scanning (AddZodSharp(o => o.ScanAssemblies...)) is not trim-safe: register the generated AddZodSharp{Assembly}Schemas() instead. Validating a bound request this way reports each value object member under its full path (CustomerEmail.Value), under Native AOT as under the JIT.
Purview.ZodSharp.Http Safe, and runs in Blazor WebAssembly: every API takes a JsonTypeInfo<T>.
Purview.ZodSharp.Blazor, .Extensions.Validation Per their own guidance, safe with the generated AddZodSharp{Assembly}Schemas() registration; the model-walking fallback is annotated [RequiresUnreferencedCode]. The packed-consumer gate runs Purview.ZodSharp.Blazor under WebAssembly AOT.

A strict value object’s failure is re-rooted under the request member by ZodExceptionHandler under Native AOT too (on .NET 10 with HandleBindingFailures = true; see ZodSharp Validation).

What does not work is ScalarJsonConverterFactory. Under Native AOT it cannot build its converters, and every request it handles returns 500. The generated converters need no registration.

For Purview.ZodSharp.Http and for an ASP.NET Core app’s own JsonSerializerContext, register the generated converters when the value objects are declared in the same project, as for any source-generated context.

The Entity Framework Core mapping the generator emits - converters, comparers, the ConfigureValueObjects registry and the key value generators - raises no trim or AOT warning of its own. A JSON-mapped complex value object (EFMapping = EntityFrameworkMapping.Json) is written to its column through its generated converter and ValueObjectJson, not through JsonSerializer. Without the reflection-based metadata a trimmed or AOT application turns off, a member of a JSON-mapped value object is written through its value object’s converter or the built-in converter for a primitive; an enum member, or a custom one, throws NotSupportedException naming the type.

EF Core itself is not fully trim- or AOT-compatible: its DbContext constructor and migrations APIs are annotated [RequiresUnreferencedCode]/[RequiresDynamicCode], so a trimmed or AOT application warns at those calls. See EF Core’s guidance on compiled models and precompiled queries.

The built-in inputs bind primitives. A small input over a string-backed scalar binds the value object itself, and uses Hydrate - which never validates - so the form’s validator reports what is wrong with the value, through the value object’s own rules, instead of the input rejecting it before any rule runs:

public sealed class InputScalar<TScalar> : InputBase<TScalar>
where TScalar : struct, IScalarValueObject<TScalar, string>
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
if (!string.IsNullOrEmpty(NameAttributeValue))
builder.AddAttribute(2, "name", NameAttributeValue);
builder.AddAttribute(3, "class", CssClass);
builder.AddAttribute(4, "value", CurrentValueAsString);
builder.AddAttribute(5, "onchange",
EventCallback.Factory.CreateBinder<string?>(this, v => CurrentValueAsString = v, CurrentValueAsString));
builder.CloseElement();
}
protected override string? FormatValueAsString(TScalar value) => value.Value;
protected override bool TryParseValueFromString(
string? value, [MaybeNullWhen(false)] out TScalar result, [NotNullWhen(false)] out string? validationErrorMessage)
{
result = TScalar.Hydrate(value ?? string.Empty);
validationErrorMessage = null;
return true;
}
}
<InputScalar TScalar="EmailAddress" @bind-Value="model.Email" />

In a statically rendered form, a posted value binds through the value object’s generated IParsable implementation, whose TryParse runs TryCreate: an invalid value is reported as a form mapping error on its field (“The value ‘abc’ is not valid for ‘Postcode’.”), and a valid one arrives normalized.

Mark the form model and its value objects [ZodSchema] and register the generated schemas and ZodSharp for Blazor (Purview.ZodSharp.Blazor, net11.0) in the client and the server:

builder.Services.AddZodSharpMyAppSchemas(); // generated: AddZodSharp{Assembly}Schemas()
builder.Services.AddZodSharpBlazor();
<EditForm EditContext="editContext">
<ZodValidator />
<InputScalar TScalar="EmailAddress" @bind-Value="model.Email" />
<ZodValidationMessage For="() => model.Email" />
<ZodValidationSummary />
</EditForm>

Each value object member is validated by the value object’s own generated schema, so the rules live with the value object rather than being repeated on every form.

An error inside a value object is located at Email.Value, but a struct cannot be a FieldIdentifier model, so ZodSharp places it on the owning member’s field: <ZodValidationMessage For="() => model.Email" /> (or <ValidationMessage>) shows it next to the value object’s input, with <ZodValidator> and with <DataAnnotationsValidator />, on Blazor Server and in WebAssembly alike. A scalar’s messages name the owning member, not its own Value: Field 'Email' must be a valid email address. A type-level rule such as [NonSentinel(Message = "Choose a plan.")] is located at the member itself and keeps its own message.

A form or request model may declare a value object member as [Required] and nullable (EmailAddress?): a missing value is reported at the member, and a present one is validated by the value object’s own schema.