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 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 AdCodicem.ValueObjects;
using AdCodicem.ValueObjects.Annotations;
namespace Shop;
[ValueObject<string>(MaxLength = 254, Pattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$")]
public readonly partial struct EmailAddress : IValueObjectNormalizer<string>
{
public static string NormalizeValue(string value) => value.Trim().ToLowerInvariant();
}
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 and a pattern.IValueObjectNormalizer<string>declares a rule that does need code. The interface is how the generator findsNormalizeValue, and how the compiler checks its signature.
The two namespaces are the only ones a declaration needs: AdCodicem.ValueObjects for the contracts and hook
interfaces, AdCodicem.ValueObjects.Annotations for the attributes. Most projects add them as global 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, 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, and is what every integration uses at a boundary.
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.