Getting started
This tutorial installs the package, declares a first value object, and looks at what the generator writes for it. It needs the .NET 10 SDK or later, and takes a few minutes.
Install the package
dotnet add package AdCodicem.ValueObjects
That one package holds the contracts, the source generator and the analyzers. The integrations — EF Core, ASP.NET Core, OpenAPI, source-generated JSON and the others — are separate packages, added when you reach that boundary; Packages lists them.
Declare a value object
An email address makes a good first candidate: it has a format, it should not care how the user typed it, and
it is routinely passed around as a bare string.
using System.Text.RegularExpressions;
using AdCodicem.ValueObjects;
using AdCodicem.ValueObjects.Annotations;
namespace Shop;
[ValueObject<string>(MaxLength = 254)]
public readonly partial struct EmailAddress : IValueObjectNormalizer<string>, IValueObjectPatternValidator
{
public static string NormalizeValue(string value) => value.Trim().ToLowerInvariant();
[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)]
public static partial Regex Pattern { get; }
}
Three things make it a value object:
readonly partial struct.partial, because the generator adds the implementation to the same type. Areadonly struct, because a value object is a value: Design decisions explains why neither a class nor arecord structis accepted.[ValueObject<string>]names the underlying type and declares the rules that need no code — here a maximum length.- The hook interfaces declare the rules that do need code. The interface is how the generator finds the
member, and how the compiler checks its signature.
IValueObjectNormalizer<string>findsNormalizeValue.IValueObjectPatternValidatorfindsPattern, a[GeneratedRegex]property that the .NET regex source generator compiles; its text is also the pattern the OpenAPI schema publishes.
Two namespaces are all a declaration needs: AdCodicem.ValueObjects for the contracts and hook interfaces,
AdCodicem.ValueObjects.Annotations for the attributes. Most projects add them as global usings. A pattern adds a
third, System.Text.RegularExpressions, which is not among the implicit usings.
Use it
var email = EmailAddress.Create(" Ada@Example.COM ");
email.Value // "ada@example.com"
EmailAddress.TryCreate("not an email", out _, out var validation) // false, and nothing thrown
validation.ErrorCode // "value_object.invalid_format"
validation.ErrorMessage // "The value does not match the expected format."
EmailAddress.Create("not an email"); // throws ValueObjectException, carrying the same code
Every way in — Create, TryCreate, Parse, TryParse, JSON deserialization, model binding — runs the same
steps in the same order: normalize, check the declared rules and the pattern, run your own validator if there
is one, then assign. So an EmailAddress that exists is normalized and valid; no code that receives one has to
check it again.
Create throws, and suits domain code where a rejected value is a bug. TryCreate returns the reason instead of
throwing; the integrations go through it, or through TryParse, wherever outside input arrives.
What the generator wrote
Nothing else is needed. From that declaration the generator produced:
Value,Create,TryCreateandCreateUnchecked;NormalizeandValidate, which run your rules and the declared ones;ParseandTryParsefrom astringor a span, andToString/TryFormat;- equality, hashing and ordering, with their operators;
- a
System.Text.Jsonconverter and aTypeConverter, so the value travels as a bare JSON string; - a registration that makes the type discoverable at run time.
Generated members lists them precisely. To read the code itself, set
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in the project: the files land under
obj/…/generated/AdCodicem.ValueObjects.Generators/.
default is a build error
A struct can always be brought into existence without its constructor, which would skip every rule:
EmailAddress missing = default; // error VO0010
var alsoMissing = new EmailAddress(); // error VO0010
The VO0010 analyzer makes both a build error. Absence is expressed the usual way, with EmailAddress?.
Next steps
- Validation and normalization — the declared rules and the hooks, in the order they run.
- From request to database — the same types through ASP.NET Core, the OpenAPI document and EF Core.
- Primitive obsession — the problem all of this answers.