Design decisions worth knowing
A readonly partial struct, not a record struct. A record's with expression and field-wise equality
would both bypass validation and the configured comparison. The generator owns equality, ordering and hashing
so that Comparison = StringComparison.OrdinalIgnoreCase actually means something.
A struct, even when the underlying type is a string. Holding 100 000 struct wrappers allocates exactly
what holding 100 000 bare strings allocates, to the byte; the class equivalent costs four times the memory and
2.3x the time, because a reference type adds 24 bytes of header, method table pointer and field per instance.
The struct gives that back only when it crosses a non-generic boundary and boxes, so the generated equality,
hashing and comparison exist to keep the hot paths generic — dictionary lookups and sorts on value objects
allocate nothing. See Benchmarks for the numbers and for where the struct loses.
default(Iban) is a build error. A struct can always be brought into existence uninitialized, and that is
the one hole a struct value object cannot close by itself. The VO0010 analyzer closes it at compile time,
which is what makes the struct representation — zero allocation, no null — safe to choose. Opt out per type
with AllowDefault = true.
Rejection is not an exception. Validate returns a readonly struct that allocates nothing when the
value is valid. The integrations that take outside input go through TryCreate or TryParse and report a
refusal in their own terms: a JSON exception, a model state error, a FluentValidation failure, a Dapper
DataException (what each one throws). Create throws ValueObjectException, and is
for the call sites that want it; a strict EF Core read goes through it, and fails the query. Validation is
fail-fast: the first violated rule wins.
Normalize, then validate, then assign. So a non-default instance is by construction both normalized and
valid. It happens on construction, on parsing, on deserialization and on model binding — but not when
materializing a row from the database, which is the hottest path in most applications and reads values this
same application wrote. ConfigureValueObjects(strict: true) turns that back on for a table another system
also writes to.
Rules are declared once. MaxLength = 34 validates the value, sizes the EF Core column, and becomes the
maxLength keyword of the OpenAPI schema. [KnownValue] entries become named constants, a frozen membership
lookup, and the enum keyword of the schema.
Constraints that shape the generated code
A few constraints of the .NET compiler and source generator model are the reason some of the generated code looks the way it does — worth knowing if a compile error in generated code is confusing:
- Source generators never observe each other's output. The
[JsonConverter]this generator writes is invisible to the System.Text.Json generator, which is the entire reasonAdCodicem.ValueObjects.Jsonexists: a hand-written converter factory the STJ generator can see. The same constraint is why a pattern is a hook: the regex generator only sees code a person wrote, so this generator cannot write a[GeneratedRegex]itself. The consumer writes it instead, as thePatternproperty ofIValueObjectPatternValidator, and the generator reads its text off the attribute for the schema. ThePatternoption it replaces had to build itsRegexat run time, which native AOT interprets, and is deprecated. - Generated code cannot rely on the consumer's usings. Every type and extension method is fully qualified
in emitted code. A consumer with
ImplicitUsingsdisabled would otherwise get a compile error in code they cannot edit. static virtualandstatic abstractinterface members are reachable only through a type parameter. That's a C# rule, not a choice this library made — it's why the generator emits concrete members for each value object rather than relying on default interface implementations.
Next: Entity Identifiers, which build on the same generator for a different shape of value.