Validation and normalization
A value object is only worth having if it refuses what it should refuse. This tutorial builds up the rules of two types, one step at a time, and ends with the order in which they run.
Rules that need no code
Most rules are shapes: a length, a range. A length is declared on the attribute.
[ValueObject<string>(MinLength = 8, MaxLength = 8)]
public readonly partial struct ProductCode;
ProductCode.TryCreate("ABC-1234", out _) succeeds, while "ABC-12" fails with value_object.too_short. A
declared rule does more than validate: MaxLength also sizes the EF Core column, and MinLength and
MaxLength both appear in the OpenAPI schema, so the rule is stated once for every boundary.
Numbers, dates and times take bounds instead. A bound is a value of the underlying type, declared through
IValueObjectMinimum<T> and IValueObjectMaximum<T>, so the compiler checks its type and any expression of that
type can build it:
[ValueObject<int>]
public readonly partial struct Quantity : IValueObjectMinimum<int>, IValueObjectMaximum<int>
{
public static int Minimum => 1;
public static int Maximum => 999;
}
Quantity.TryCreate(0, out _) fails with value_object.out_of_range, and the OpenAPI schema publishes both bounds
as minimum and maximum. A bound is a constant, written as an expression-bodied property; a bound relative to the
clock is a rule, and belongs in a validator, below. The Minimum and Maximum
options of [ValueObject<T>] once did this job, with the bound written as text; they are deprecated (VO0028).
A pattern
A format is a shape too, but a regular expression is compiled by the .NET regex source generator, which only
reads code a person wrote: the value object generator cannot write it for you. A pattern is therefore declared
through IValueObjectPatternValidator, as a [GeneratedRegex] property named Pattern:
[ValueObject<string>(MinLength = 8, MaxLength = 8)]
public readonly partial struct ProductCode : IValueObjectPatternValidator
{
[GeneratedRegex("^[A-Z]{3}-[0-9]{4}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)]
public static partial Regex Pattern { get; }
}
Now "abc-1234" fails with value_object.invalid_format. The file needs using System.Text.RegularExpressions;,
which is not among the implicit usings. The rule is still stated once: the value object generator reads the
pattern's text off [GeneratedRegex] when the type compiles, and the OpenAPI schema publishes it beside the
lengths. Keep both arguments: CultureInvariant makes the match independent of the culture, and without
matchTimeoutMilliseconds a pathological input could hold a thread, which VO0026 reports. The
authoring reference covers the rest. The Pattern option of
[ValueObject<T>] once did this job; it builds its regular expression at run time, and is deprecated (VO0021).
Normalizing what comes in
"abc-1234" is plainly the same product as "ABC-1234". Rejecting it would be pedantic; accepting both
spellings as different values would be worse. Normalization brings every spelling to one:
[ValueObject<string>(MinLength = 8, MaxLength = 8)]
public readonly partial struct ProductCode : IValueObjectNormalizer<string>, IValueObjectPatternValidator
{
public static string NormalizeValue(string value) => value.Trim().ToUpperInvariant();
[GeneratedRegex("^[A-Z]{3}-[0-9]{4}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)]
public static partial Regex Pattern { get; }
}
Normalization runs before validation, so the pattern only ever sees upper case, and the stored value is
canonical: equality, hashing, a unique index and a GROUP BY all agree without anyone passing a comparer.
A normalizer has three obligations:
- It is idempotent. Normalizing a normalized value changes nothing; the contract kit checks it.
- It never rejects. A value that cannot be normalized is left as it is, for validation to refuse.
- It is never called with
null. The generatedNormalizeguards the null before calling your method.
For a string value object on a hot path, IValueObjectSpanNormalizer lets parsing and JSON reading normalize
straight from the incoming text; the authoring reference shows how.
Rules that need code
Some rules are not shapes. A delivery date must not fall on a Sunday; an IBAN must have correct check digits. Those go in a validator:
[ValueObject<DateOnly>]
public readonly partial struct DeliveryDate : IValueObjectMinimum<DateOnly>, IValueObjectValidator<DateOnly>
{
public static DateOnly Minimum => new(2020, 1, 1);
public static ValidationResult ValidateValue(in DateOnly value)
=> value.DayOfWeek == DayOfWeek.Sunday
? ValidationResult.Failure("delivery_date.sunday", "Nothing is delivered on a Sunday.")
: ValidationResult.Success;
}
ValidationResult is a readonly struct whose success state is default, so accepting a value allocates
nothing. Rejecting one is a return value, never an exception: the validator must not throw.
The factories cover the common cases — Required, InvalidFormat, OutOfRange, TooShort, TooLong — and
reuse the framework's error codes. For a rule of your own, use Failure with a code of your own, as above. The
code is what an API client branches on, so it should be stable and specific: delivery_date.sunday tells a
client something value_object.invalid_format does not.
The order things run in
Every entry point — Create, TryCreate, Parse, TryParse, JSON, model binding — runs the same sequence, and
stops at the first rule that fails:
NormalizeValue, if the type declares it (anullgoes past it untouched).nullis rejected withvalue_object.required, and so is an empty string unlessAllowEmpty = true— which includes a string that normalization emptied, such as" "after aTrim.MinLength, thenMaxLength.- The pattern of
IValueObjectPatternValidator, or of the deprecatedPatternoption. - The bound of
IValueObjectMinimum<T>, then ofIValueObjectMaximum<T>, or of the deprecatedMinimumandMaximumoptions. - Membership of a closed set of known values.
ValidateValue, if the type declares it.
So a validator only ever sees a value that already satisfies every declared rule. The IBAN check-digit
validator on the introduction indexes into the value without checking its length first,
because MinLength = 15 has already run.
Because validation is fail-fast, a rejection carries exactly one reason: the first rule that failed.
When the interface is missing
A hook is found through its interface, not its name. Write NormalizeValue without declaring
IValueObjectNormalizer<string> and the code compiles, but the generator never calls it. The VO0011 warning
reports exactly that mistake. It reports a public static Regex Pattern written without
IValueObjectPatternValidator too, unless the type implements another hook, which may run it itself.
Next: Known values, for types whose accepted values are a fixed list.