These pages describe 0.2.1, the latest stable release. # AdCodicem.ValueObjects An answer to primitive obsession for .NET 10: single-value DDD value objects, generated at compile time, with no reflection and no allocation on the paths that matter. ## Primitive obsession ```csharp skip Task PayAsync(string customerId, string iban, decimal amount); ``` A call to it compiles with the two strings swapped, `"hello"` passes for a bank account, and the signature says nothing about what an IBAN is. So every layer says it again: the controller checks the format, a migration guesses the column width, the OpenAPI document settles for `string`, and nothing keeps the three in agreement. That is primitive obsession — domain concepts carried as bare `string`, `int` and `Guid`. The remedy is well known: give each concept a type that cannot hold an invalid value. It stays rare because the type is only the start. It also needs equality, parsing, formatting, a JSON converter, an EF Core value converter, a model binder and a schema — a few hundred lines per concept, which is why codebases drift back to `string`. Here the type costs one declaration. Its rules are written once and carried into JSON, the database, model binding and the OpenAPI document, so they cannot drift apart. This compiles as it stands: ```csharp using AdCodicem.ValueObjects; using AdCodicem.ValueObjects.Annotations; namespace Banking; [ValueObject( MinLength = 15, MaxLength = 34, Pattern = "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$", SchemaFormat = "iban")] public readonly partial struct Iban : IValueObjectNormalizer, IValueObjectValidator { // Runs first, on every way in: "fr76 3000 6000 …" and "FR7630006000…" are the same account. public static string NormalizeValue(string value) => value.Replace(" ", "").Replace("-", "").ToUpperInvariant(); // Runs once the declared length and pattern hold: the ISO 7064 MOD-97-10 check digits. public static ValidationResult ValidateValue(in string value) { var remainder = 0; for (var i = 0; i < value.Length; i++) { var c = value[(i + 4) % value.Length]; remainder = char.IsAsciiDigit(c) ? ((remainder * 10) + (c - '0')) % 97 : ((remainder * 100) + (c - 'A' + 10)) % 97; } return remainder == 1 ? ValidationResult.Success : ValidationResult.InvalidFormat("The IBAN check digits are incorrect."); } } ``` That declaration generates the constructor, `Create` / `TryCreate` / `CreateUnchecked`, `Parse` / `TryParse` (string and span), `ToString` / `TryFormat`, equality, ordering, the `System.Text.Json` converter, the `TypeConverter`, and the runtime registration — around 400 lines you no longer maintain. ```csharp skip var iban = Iban.Create("fr76 3000 6000 0112 3456 7890 189"); iban.Value // "FR7630006000011234567890189" Iban.TryCreate("FR00 0000", out _) // false: rejection is not an exception JsonSerializer.Serialize(new { iban }) // {"iban":"FR7630006000011234567890189"} Task PayAsync(CustomerId customer, Iban iban, decimal amount); // swapping the two no longer compiles ``` An IBAN crosses every boundary as its underlying type: a JSON string, a `VARCHAR`, a query-string parameter — never an object wrapper. Consumers define their own value objects; this framework ships the generator. Already using Vogen, StronglyTypedId or Thinktecture? [How this library compares](./explanation/comparison.md), and [how to migrate](./how-to/migrating.md). ## How this documentation is organized - **Tutorials** teach by building something, one step at a time. Start with [Getting started](./getting-started.md), then follow the sequence. - **How-to guides** answer a precise question — wiring EF Core, returning error codes, formatting a value — for a reader who already knows the basics. - **Reference** lists the surface without commentary: attribute options, generated members, error codes, diagnostics, and the API reference generated from the source. - **Explanation** is about why: primitive obsession, the design decisions and their costs, the benchmarks behind them, and how this library compares with others. - The **[FAQ](./faq.md)** gives short answers and points to the page that explains each one. Next: [Getting started](./getting-started.md). # Frequently asked questions ## About the library ### What problem does it solve? Primitive obsession: domain concepts carried as bare `string`, `int` and `Guid`, with their rules restated — and drifting apart — in every layer. [Primitive obsession](./explanation/primitive-obsession.md) describes the problem and how the library answers it. ### Which versions of .NET are supported? .NET 10: every package targets `net10.0`, and so must the project that uses them. If your application cannot move to .NET 10, [Compared with other libraries](./explanation/comparison.md#where-the-others-are-stronger) names alternatives that run on older frameworks. ### Is it ready for production? It follows semantic versioning and is still at 0.x, which means the public surface may change between minor releases; every change is recorded in the [changelog](https://github.com/AdCodicem/AdCodicem.ValueObjects/blob/main/CHANGELOG.md). Stable releases are cut by hand, and every merge to `main` publishes a preview package in between. ### How does it compare with Vogen, StronglyTypedId or Thinktecture? [Compared with other libraries](./explanation/comparison.md) has a feature table and says where each of them is the better choice. [Migrate from another library](./how-to/migrating.md) maps their surface onto this one. ## Declaring value objects ### Why a `readonly struct`, and not a class or a `record struct`? A struct wrapping a `string` costs exactly what the `string` costs, to the byte; a class adds 24 bytes per instance. A `record struct` is refused because its `with` expression and field-wise equality would bypass validation and the declared comparison. [Design decisions](./design-decisions.md) has the reasoning and [Benchmarks](./benchmarks.md) the numbers. ### Which underlying types can I use? `string`, `Guid`, `bool`, `char`, every built-in integer including `Int128` and `UInt128`, `decimal`, `double`, `float`, `DateOnly`, `TimeOnly`, `DateTime`, `DateTimeOffset` and `TimeSpan`. Anything else is `VO0003`. ### Can a value object hold several values? No. A concept made of several values — an amount and its currency, a postal address — belongs in an ordinary type whose members are value objects. ### My normalization or validation rule is never called. Why? The interface is missing. A hook is found through `IValueObjectNormalizer`, `IValueObjectValidator` or a formatter interface, not through its name, and `VO0011` warns about a method that looks like a rule but is not declared as one. ### How do I see the code the generator writes? Set `true` in the project and build: the files land under `obj/…/generated/AdCodicem.ValueObjects.Generators/`. [Generated members](./reference/generated-members.md) lists what to expect. ## Using value objects ### How do I represent a missing value? With `T?`. `default(T)` and `new T()` are build errors (`VO0010`), because an uninitialized struct would skip every rule. A type whose zero value is genuinely meaningful can opt out with `AllowDefault = true`. ### Can I throw my own exception type? `Create` always throws `ValueObjectException`, which carries the error code, the type and the attempted value. To throw something else, call `TryCreate` and throw your own exception from the `ValidationResult` it returns. ### Can a value object be a dictionary key, or an EF Core key? Yes to both. The generated equality and hashing make dictionary lookups allocation-free, and a value object serializes as a JSON property name. In EF Core it can be a primary or a foreign key; for a string key compared without case, give the column a matching collation. ### Why are values read from the database not validated? Because the EF Core read path is the hottest in most applications, and it reads values the same application validated when writing them. `ConfigureValueObjects(strict: true, …)` validates reads for tables other systems also write to. ### Does it work with minimal APIs? Yes, with nothing to install: a value object implements `IParsable`, which is what minimal API parameter binding looks for. ### Does it work with Swashbuckle? No. The OpenAPI integration targets the built-in .NET stack, `Microsoft.AspNetCore.OpenApi`. ## Performance ### Does a value object cost more than the primitive it wraps? Holding one costs nothing more: a struct wrapper has the size of the value it wraps. Validation costs what the rules cost, on the way in only. The struct gives something back when it is boxed — passed as `object` or through a non-generic interface — which is why the generated equality, hashing and comparison keep the hot paths generic. [Benchmarks](./benchmarks.md) measures each case. ### Does it use reflection? Not in the generated code, and not to find value objects: a generated module initializer registers each type at start-up. The contracts, the generated code, the JSON package, FluentValidation and identifiers are marked AOT-compatible and built with the trimming and AOT analyzers on. The EF Core, ASP.NET Core, OpenAPI, Dapper and Newtonsoft.Json integrations are not, because the frameworks they plug into are not. # 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 ```bash 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](./packages.md) 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`. ```csharp using AdCodicem.ValueObjects; using AdCodicem.ValueObjects.Annotations; namespace Shop; [ValueObject(MaxLength = 254, Pattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$")] public readonly partial struct EmailAddress : IValueObjectNormalizer { 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. A `readonly struct`, because a value object is a value: [Design decisions](./design-decisions.md) explains why neither a class nor a `record struct` is accepted. - **`[ValueObject]`** names the underlying type and declares the rules that need no code — here a maximum length and a pattern. - **`IValueObjectNormalizer`** declares a rule that does need code. The interface is how the generator finds `NormalizeValue`, 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 ```csharp skip 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`, `TryCreate` and `CreateUnchecked`; - `Normalize` and `Validate`, which run your rules and the declared ones; - `Parse` and `TryParse` from a `string` or a span, and `ToString` / `TryFormat`; - equality, hashing and ordering, with their operators; - a `System.Text.Json` converter and a `TypeConverter`, so the value travels as a bare JSON string; - a registration that makes the type discoverable at run time. [Generated members](./reference/generated-members.md) lists them precisely. To read the code itself, set `true` 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: ```csharp skip 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](./tutorials/validation-and-normalization.md) — the declared rules and the hooks, in the order they run. - [From request to database](./tutorials/request-to-database.md) — the same types through ASP.NET Core, the OpenAPI document and EF Core. - [Primitive obsession](./explanation/primitive-obsession.md) — the problem all of this answers. # 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 pattern, a range. Those are declared on the attribute. ```csharp [ValueObject(MinLength = 8, MaxLength = 8, Pattern = "^[A-Z]{3}-[0-9]{4}$")] public readonly partial struct ProductCode; ``` `ProductCode.TryCreate("ABC-1234", out _)` succeeds, while `"ABC-12"` fails with `value_object.too_short` and `"abc-1234"` with `value_object.invalid_format`. A declared rule does more than validate: `MaxLength` also sizes the EF Core column and `MaxLength`, `MinLength` and `Pattern` all appear in the OpenAPI schema, so the rule is stated once for every boundary. Numbers, dates and times take bounds instead. They are written as invariant-culture text, so a `decimal` or a `DateOnly` keeps its full precision, and they are parsed at compile time — a bound that does not parse is `VO0004`: ```csharp [ValueObject(Minimum = "1", Maximum = "999")] public readonly partial struct Quantity; ``` ## 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: ```csharp [ValueObject(MinLength = 8, MaxLength = 8, Pattern = "^[A-Z]{3}-[0-9]{4}$")] public readonly partial struct ProductCode : IValueObjectNormalizer { public static string NormalizeValue(string value) => value.Trim().ToUpperInvariant(); } ``` 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 generated `Normalize` guards 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](../authoring-guide.md#hooks) 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: ```csharp [ValueObject(Minimum = "2020-01-01")] public readonly partial struct DeliveryDate : IValueObjectValidator { 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: 1. `NormalizeValue`, if the type declares it (a `null` goes past it untouched). 2. `null` is rejected with `value_object.required`, and so is an empty string unless `AllowEmpty = true` — which includes a string that normalization emptied, such as `" "` after a `Trim`. 3. `MinLength`, then `MaxLength`. 4. `Pattern`. 5. `Minimum`, then `Maximum`. 6. Membership of a closed set of [known values](./known-values.md). 7. `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](../introduction.md) 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` and the code compiles, but the generator never calls it. The `VO0011` warning reports exactly that mistake. Next: [Known values](./known-values.md), for types whose accepted values are a fixed list. # Known values Country codes, currency codes, status codes: reference data is usually a short list of values that the domain names and the wire carries as text. A C# `enum` names them but carries neither validation nor a stable wire format; a bare `string` carries the format and nothing else. A value object with known values does both. ## Declare the values ```csharp [ValueObject(ValueSet = ValueSetKind.Closed, MinLength = 2, MaxLength = 2, SchemaFormat = "iso-3166-alpha2")] [KnownValue("France", "FR", Description = "France")] [KnownValue("Belgium", "BE", Description = "Belgium")] [KnownValue("Luxembourg", "LU", Description = "Luxembourg")] public readonly partial struct CountryCode : IValueObjectNormalizer { public static string NormalizeValue(string value) => value.Trim().ToUpperInvariant(); } ``` Each `[KnownValue]` takes a member name and a value. The generator turns them into: - **Named constants**: `CountryCode.France`, `CountryCode.Belgium`, `CountryCode.Luxembourg`. - **`CountryCode.KnownValues`**: every known value, as an `ImmutableArray` in declaration order. - **A membership check**: with `ValueSet = ValueSetKind.Closed`, anything else is rejected with `value_object.not_a_known_value`, through a frozen lookup. - **An OpenAPI `enum`**: `["FR", "BE", "LU"]`, so clients see the accepted values without anyone restating them. ## Use it ```csharp skip var country = CountryCode.Create(" be "); // normalized, then found in the set country == CountryCode.Belgium // true CountryCode.TryCreate("ZZ", out _, out var validation); validation.ErrorCode // "value_object.not_a_known_value" foreach (var known in CountryCode.KnownValues) // FR, BE, LU { } ``` The membership check runs after normalization and the declared rules, like every other rule. `" be "` is accepted because it becomes `"BE"` first. ## Open sets Without `ValueSet = ValueSetKind.Closed`, the set is open: the constants and `KnownValues` are still generated, but any value that satisfies the other rules is accepted. That suits a list the domain names only in part, such as the currencies the application treats specially among all ISO 4217 codes. ## Values that are not strings An attribute argument can only be a constant, so a `Guid`, a `decimal` or a `DateOnly` is written as invariant-culture text and converted at compile time. A value that does not convert is `VO0013`; a member name that is not a valid C# identifier is `VO0006`; a closed set with no value at all is `VO0005`, since no value could ever be valid. Next: [From request to database](./request-to-database.md), which puts these types behind an API. # From request to database This tutorial puts value objects behind a small ASP.NET Core API that stores customers with EF Core. Every rule is declared once, on the type, and each boundary picks it up from there. The complete application is the [sample API](https://github.com/AdCodicem/AdCodicem.ValueObjects/tree/main/samples/AdCodicem.ValueObjects.Sample.Api) in the repository. ## The packages ```bash dotnet add package AdCodicem.ValueObjects dotnet add package AdCodicem.ValueObjects.AspNetCore dotnet add package AdCodicem.ValueObjects.OpenApi dotnet add package AdCodicem.ValueObjects.EntityFrameworkCore ``` ## The domain Three types, each declaring its own rules: ```csharp [ValueObject] public readonly partial struct CustomerId : IValueObjectValidator { public static CustomerId New() => CreateUnchecked(Guid.CreateVersion7()); public static ValidationResult ValidateValue(in Guid value) => value == Guid.Empty ? ValidationResult.Required("A customer identifier must not be empty.") : ValidationResult.Success; } [ValueObject(MaxLength = 254, Pattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$", SchemaFormat = "email")] public readonly partial struct EmailAddress : IValueObjectNormalizer { public static string NormalizeValue(string value) => value.Trim().ToLowerInvariant(); } [ValueObject(ValueSet = ValueSetKind.Closed, MinLength = 2, MaxLength = 2)] [KnownValue("France", "FR")] [KnownValue("Belgium", "BE")] [KnownValue("Luxembourg", "LU")] public readonly partial struct CountryCode : IValueObjectNormalizer { public static string NormalizeValue(string value) => value.Trim().ToUpperInvariant(); } ``` `CustomerId.New()` is a member of your own, next to the generated ones. `CreateUnchecked` is legitimate there because the application produced the value itself; it never is for input that comes from outside. The entity and the contracts use the types directly, with no `string` in sight: ```csharp skip public sealed class Customer { public CustomerId Id { get; set; } public EmailAddress Email { get; set; } public CountryCode Country { get; set; } } public sealed record CreateCustomerRequest(EmailAddress Email, CountryCode Country); public sealed record CustomerResponse(CustomerId Id, EmailAddress Email, CountryCode Country); ``` ## The API ```csharp skip var builder = WebApplication.CreateBuilder(args); // Model binding for routes, query strings and headers, and JSON for request and response bodies. builder.Services.AddControllers().AddValueObjects(); // The error code of the violated rule is added to the automatic 400 response. builder.Services.Configure(options => options.AddValueObjectProblemDetails()); // Value objects are documented as their underlying type, with the rules declared on them. builder.Services.AddOpenApi(options => options.AddValueObjects()); builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); ``` A controller takes the value objects as parameters, from any source: ```csharp skip [HttpGet("{id}")] public async Task> GetById(CustomerId id, CancellationToken cancellationToken) [HttpGet] public async Task> List([FromQuery] CountryCode? country, CancellationToken cancellationToken) [HttpPost] public async Task> Create([FromBody] CreateCustomerRequest request, CancellationToken cancellationToken) ``` A minimal API needs none of the above: a generated value object implements `IParsable`, which is what minimal API parameter binding looks for. ```csharp skip app.MapGet("/customers/{id}", async (CustomerId id, ShopDbContext database) => /* … */); ``` ## The database ```csharp skip public sealed class ShopDbContext(DbContextOptions options) : DbContext(options) { public DbSet Customers => Set(); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => configurationBuilder.ConfigureValueObjects(typeof(CustomerId).Assembly); } ``` That one call maps every value object of the assembly: `CustomerId` to the provider's native GUID column (`uuid`, `uniqueidentifier`), and `EmailAddress` to text bounded at 254 characters because the type says 254 — `character varying(254)` on PostgreSQL, `nvarchar(254)` on SQL Server. A LINQ query compares value objects the way it would compare the underlying values — `Where(c => c.Country == country)` becomes an ordinary `WHERE` on the column. ## What you get **Bodies carry bare values.** A request sends `{"email": " Ada@Example.COM ", "country": "fr"}` and the response comes back as `{"id": "0193…", "email": "ada@example.com", "country": "FR"}`: normalized on the way in, and never wrapped in an object. **A rejected value names its rule.** `GET /customers?country=ZZ` fails model binding, and the problem details response carries the stable code next to the message: ```json { "title": "One or more validation errors occurred.", "status": 400, "errors": { "country": ["The value is not one of the accepted values."] }, "errorCodes": { "country": "value_object.not_a_known_value" } } ``` `errorCodes` covers values bound from the route, the query string, headers and forms. A value inside a JSON body is rejected by the serializer instead: the 400 names the member but carries no code. When a client needs codes for a payload, validate it with [FluentValidation](../how-to/fluentvalidation.md), which reports the value object's own codes. **The OpenAPI document states the rules.** `EmailAddress` is documented as `{"type": "string", "format": "email", "maxLength": 254, "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"}` and `CountryCode` carries `"enum": ["FR", "BE", "LU"]`. Nothing was written for it beyond the declaration. **The column is sized by the type.** Change `MaxLength` and the next migration resizes the column. The rule has one home. ## Reading rows back Materializing a row does not validate the value again: it uses `CreateUnchecked`, because it is the hottest path in most applications and reads values this same application validated when it wrote them. For a table that another system also writes to, turn validation back on: ```csharp skip configurationBuilder.ConfigureValueObjects(strict: true, typeof(CustomerId).Assembly); ``` Next: [Public identifiers](./public-identifiers.md), for identifiers that clients see. # Public identifiers An identifier that clients see — in a URL, a webhook, a support ticket — has needs of its own. It should say what it identifies, survive being read over the phone, refuse to be mistaken for another kind of identifier, and still make a good primary key. `AdCodicem.ValueObjects.Identifiers` generates identifiers in the shape made familiar by Stripe: `acc_1kcv3ahrz6dmv29gqy5cv`. ```bash dotnet add package AdCodicem.ValueObjects.Identifiers dotnet add package AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore ``` ## Declare one ```csharp [EntityId("acc")] public readonly partial struct AccountId; ``` That is the whole declaration. `AccountId` is a value object like any other — the same parsing, JSON, model binding, OpenAPI schema and contract kit — with a few members of its own: ```csharp skip var id = AccountId.New(); // acc_1kcv3ahrz6dmv29gqy5cv, from the ambient clock and a CSPRNG AccountId.Prefix // "acc" AccountId.Length // the total width, prefix and separator included AccountId.TryParse("cus_1kcv3ahrz6dmv29gqy5cv", out _) // false: the prefix belongs to another type ``` ## What is in the value After the prefix and the separator comes a body in Crockford Base32: - **a time bucket**, so that new identifiers sort after old ones and inserts land at the end of the index rather than all over it. It reveals the creation time at the granularity you choose — `Hour` by default — and nothing finer; - **80 random bits**, which is what makes the identifier impossible to guess, whatever the granularity; - **a check character**, which catches any single mistyped character before a query is sent, and covers the prefix too. Upper case and the look-alikes `i`, `l` and `o` are folded on the way in, so the stored value is canonical and compares ordinally. Choose the granularity from the insert rate of the table, not from taste: ```csharp [EntityId("cus", Granularity = IdGranularity.Minute, Example = "cus_ke1kcv3ahrz6dmv29gqy5cv")] public readonly partial struct CustomerId; ``` ## Store it Map identifiers with their own convention, alongside the value object one: ```csharp skip protected override void ConfigureConventions(ModelConfigurationBuilder builder) { builder.ConfigureValueObjects(typeof(AccountId).Assembly); builder.ConfigureEntityIds(typeof(AccountId).Assembly); } ``` Because the width is fixed and the alphabet is ASCII, the column is `char(n)` rather than `varchar(n)`, and never `nchar`. The prefix is stored with the body on purpose: a raw SQL join between two tables of bare bodies would succeed silently, and one between prefixed values cannot. A binary collation makes the database compare the way the application does. It is a performance choice, since normalization already made the values canonical: ```csharp skip builder.ConfigureEntityIds(IdCollations.PostgreSql, typeof(AccountId).Assembly); // or IdCollations.SqlServer ``` ## Test with them `New()` reads an ambient clock and entropy source, so a test can pin both without injecting a factory into every aggregate: ```csharp skip using (ValueObjectIds.Use(fakeClock, deterministicBytes)) { var id = AccountId.New(); // the same value on every run } ``` The scope follows the execution flow, so tests running in parallel do not see each other's settings. ## Accepting any identifier A webhook or an audit trail may receive an identifier of any registered kind. `AnyEntityId` parses whichever prefix arrives, and converts to the concrete type once you know which one it is: ```csharp skip if (AnyEntityId.TryParse(text, provider: null, out var any) && any.TryConvertTo(out var account)) { } ``` [Entity identifiers](../entity-identifiers.md) explains the format in depth: the widths, the check character, and the alternatives that were turned down. # Serialize to JSON A value object travels as its underlying value: an `Iban` is a JSON string, a `Quantity` a JSON number. It is never wrapped in an object, so a client sees exactly what it would see if the property were a primitive. ## System.Text.Json, reflection-based Nothing to do. Every generated value object carries its own `[JsonConverter]`, so `JsonSerializer`, ASP.NET Core's default options and `HttpClient`'s JSON extensions all serialize it as the bare value: ```csharp skip JsonSerializer.Serialize(new { iban = Iban.Create("FR7630006000011234567890189") }); // {"iban":"FR7630006000011234567890189"} ``` Reading goes through `TryCreate`, so an incoming value is normalized and validated like any other. A rejected value, or a token of the wrong kind, throws a `JsonException` naming the type and the reason. A value object also works as a dictionary key. ## System.Text.Json, source-generated A source-generated `JsonSerializerContext` needs one line more. One source generator never sees another's output, so the System.Text.Json generator cannot see the `[JsonConverter]` this library emits. Name the hand-written factory from `AdCodicem.ValueObjects.Json`, which it *can* see: ```bash dotnet add package AdCodicem.ValueObjects.Json ``` ```csharp skip [JsonSourceGenerationOptions(Converters = [typeof(ValueObjectJsonConverterFactory)])] [JsonSerializable(typeof(AccountResponse))] public partial class ApiJsonContext : JsonSerializerContext; ``` It is declared at compile time, on the context, so there is nothing to remember when the options are built. ## Explicit options The same package adds `AddValueObjects()` to `JsonSerializerOptions`, for a composition root that prefers to say so, or for options that must cover value objects written by hand against the contracts: ```csharp skip var options = new JsonSerializerOptions().AddValueObjects(); ``` In ASP.NET Core MVC, `AddControllers().AddValueObjects()` already does this for you. ## Large integers `Int128` and `UInt128` value objects are written as JSON **strings**: a JSON number cannot carry them without losing precision in most clients. ## Newtonsoft.Json For code, SDKs and message contracts that have not moved to System.Text.Json: ```bash dotnet add package AdCodicem.ValueObjects.NewtonsoftJson ``` ```csharp skip var settings = new JsonSerializerSettings(); settings.Converters.Add(new ValueObjectConverter()); ``` One converter covers every value object, reading and writing the bare underlying value with the same rules. # Use with ASP.NET Core ## Minimal APIs Nothing to install. A generated value object implements `IParsable` and `ISpanParsable`, which is exactly what minimal API parameter binding looks for, and its `[JsonConverter]` covers request and response bodies: ```csharp skip app.MapGet("/accounts/{iban}", (Iban iban) => /* … */); ``` A value that fails to parse is answered with a 400 before the handler runs. ## MVC controllers ```bash dotnet add package AdCodicem.ValueObjects.AspNetCore ``` ```csharp skip builder.Services.AddControllers().AddValueObjects(); ``` That registers a model binder for value objects — routes, query strings, headers, forms — and the JSON options for bodies. The binder is closed over each concrete type, so binding costs one `TryParse`. An application that configures MVC directly can call `AddValueObjects()` on `MvcOptions` instead; that one adds the binder only. Nullable value objects bind as you would expect: `[FromQuery] CountryCode? country` is `null` when the parameter is absent, and a 400 when it is present and rejected. ## Problem details carrying the rule ```csharp skip builder.Services.Configure(options => options.AddValueObjectProblemDetails()); ``` When model binding rejects a value, the automatic 400 response gains an `errorCodes` member mapping each rejected parameter to the stable code of the rule it violated: ```json { "title": "One or more validation errors occurred.", "status": 400, "errors": { "country": ["The value is not one of the accepted values."] }, "errorCodes": { "country": "value_object.not_a_known_value" } } ``` A client branches on `value_object.not_a_known_value`, not on English. The member name is available as `ValueObjectProblemDetails.ExtensionName`. This covers what the model binder rejects: route values, query strings, headers and forms. A value inside a JSON body is rejected by the serializer, and the 400 carries its message but no code. ## Codes for a payload you validate yourself For a payload that carries raw text — an inbound message from another system, say — validate it with [FluentValidation](./fluentvalidation.md) and put the codes under the same member, so every 400 of the API has the same shape: ```csharp skip var result = validator.Validate(request); return result.IsValid ? Results.Accepted() : Results.ValidationProblem( result.ToDictionary(), extensions: new Dictionary { [ValueObjectProblemDetails.ExtensionName] = result.Errors.ToDictionary(failure => failure.PropertyName, failure => failure.ErrorCode), }); ``` # Document in OpenAPI ```bash dotnet add package AdCodicem.ValueObjects.OpenApi ``` ```csharp skip builder.Services.AddOpenApi(options => options.AddValueObjects()); ``` That registers a schema transformer on the built-in .NET OpenAPI stack (`Microsoft.AspNetCore.OpenApi`). A value object is then documented as what it is on the wire — its underlying type — carrying every rule declared on it: | Declared on the type | In the schema | | --- | --- | | The underlying type | `type` | | `SchemaFormat`, or the natural format of the type (`uuid`, `date`, `int64`…) | `format` | | `MinLength`, `MaxLength` | `minLength`, `maxLength` | | `Pattern` | `pattern` | | `Minimum`, `Maximum` | `minimum`, `maximum` | | `[KnownValue]` on a closed set | `enum` | | `Example` | an example | | `Description`, or the type's XML `` | `description` | So this declaration: ```csharp [ValueObject( MinLength = 15, MaxLength = 34, Pattern = "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$", SchemaFormat = "iban", Example = "FR7630006000011234567890189")] public readonly partial struct Iban; ``` is documented as a `string` of format `iban`, between 15 and 34 characters, matching the pattern — never as an object with a `value` property. There is nothing to restate in an annotation, and nothing to keep in sync: the schema comes from the declaration that validates. The transformer targets the built-in OpenAPI stack. Swashbuckle is not supported. # Use with Entity Framework Core ```bash dotnet add package AdCodicem.ValueObjects.EntityFrameworkCore ``` ## Map every value object at once ```csharp skip protected override void ConfigureConventions(ModelConfigurationBuilder builder) => builder.ConfigureValueObjects(typeof(Iban).Assembly); ``` One call maps every value object declared in the assembly to its underlying column type, with a value converter and a value comparer. It runs once, while the model is built; nothing happens per query or per row. Pass several assemblies if the value objects live in more than one. ## Columns sized by the type A value object declaring `MaxLength` also sizes its column: an `Iban` with `MaxLength = 34` becomes `character varying(34)` on PostgreSQL and `nvarchar(34)` on SQL Server, instead of unbounded text. A `Guid` value object lands in the provider's native GUID column. Change the rule on the type, and the next migration follows. LINQ queries compare value objects as they would compare the underlying values: `Where(a => a.Iban == iban)` becomes an ordinary `WHERE` on the column. ## Validation on read Materializing a row does **not** validate the value again: it uses `CreateUnchecked`. The read path is the hottest one in most applications, and it reads values this same application validated when it wrote them. For a table another system also writes to, turn validation back on: ```csharp skip builder.ConfigureValueObjects(strict: true, typeof(Iban).Assembly); ``` It then costs one normalization and validation per materialized value. ## One property, differently To depart from the convention for a single property: ```csharp skip modelBuilder.Entity() .Property(account => account.Iban) .HasValueObjectConversion(strict: true); ``` Prefer the convention everywhere else. And do not write `HasConversion` by hand for a value object: you would lose the generated comparer, and with it correct change tracking for a type whose comparison is not ordinal. ## Value objects as keys A value object makes a perfectly good key, primary or foreign. For a string key declared with `Comparison = StringComparison.OrdinalIgnoreCase`, set a case-insensitive collation on the column, or the database and the application will disagree about which values are equal. ## Public identifiers `[EntityId]` identifiers have a convention of their own, from `AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore`, which maps them to fixed-width, non-Unicode columns. Call it in addition to `ConfigureValueObjects`; [Public identifiers](../tutorials/public-identifiers.md#store-it) shows how. # Use with Dapper ```bash dotnet add package AdCodicem.ValueObjects.Dapper ``` ```csharp skip ValueObjectDapper.AddValueObjectHandlers(typeof(Iban).Assembly); // once, at start-up ``` That registers a type handler for every value object of the assembly. From then on a value object can be a query parameter and a column of a result, with no projection: ```csharp skip var account = await connection.QuerySingleOrDefaultAsync( "SELECT iban AS Iban, balance AS Balance FROM accounts WHERE iban = @iban", new { iban }); ``` Dapper keeps its handlers in a process-wide table, so the call belongs at start-up rather than per connection. Pass several assemblies if the value objects live in more than one. # Use with FluentValidation A command or an inbound message often carries raw text rather than value objects. Its validator should not state the length, the pattern and the check-digit rule of an IBAN a second time: it should defer to the type that owns them, and report the same error codes as the rest of the system. ```bash dotnet add package AdCodicem.ValueObjects.FluentValidation ``` ## Text that must become a value object ```csharp skip public sealed class ImportAccountValidator : AbstractValidator { public ImportAccountValidator() { RuleFor(request => request.Iban) .NotEmpty() .MustParseAs(typeof(Iban)); } } ``` `MustParseAs` runs the type's own parsing: normalization, the declared rules, the validator hook. A failure carries the value object's error code — `value_object.invalid_format` for a wrong check digit — as the FluentValidation `ErrorCode`. The type is passed as a `Type` rather than a type argument so the rule stays readable: C# cannot infer one type argument while another is given explicitly. ## An underlying value, without building the value object ```csharp skip RuleFor(request => request.Amount).MustSatisfy(); ``` `MustSatisfy` checks a raw underlying value against the rules of a value object without constructing one. ## An uninitialized value object ```csharp skip RuleFor(command => command.Account).NotDefault(); ``` `NotDefault` catches the one thing a struct value object cannot rule out by itself: an instance that was never constructed, arriving from a place the `VO0010` analyzer cannot see — a deserializer of another library, reflection, an array element. ## Returning the codes The codes reach an API client if you put them in the response. [ASP.NET Core](./aspnet-core.md#codes-for-a-payload-you-validate-yourself) shows how to return them under the same `errorCodes` member that model binding uses. # Compare strings without case Two users type the same email address with different capitals. There are two ways to make the value object treat them as one, and the first is usually the right one. ## Normalize, and keep the ordinal comparison ```csharp [ValueObject(MaxLength = 254)] public readonly partial struct EmailAddress : IValueObjectNormalizer { public static string NormalizeValue(string value) => value.Trim().ToLowerInvariant(); } ``` Every instance holds the lower-case form, so the default ordinal comparison is already correct — and so is a unique index in the database, a `GROUP BY`, a hash set, and any system downstream that compares the values without knowing the rule. The value is canonical wherever it goes. ## Declare the comparison, and keep the original spelling When the original spelling must be preserved — a display name, a code a partner system echoes back verbatim — declare the comparison instead: ```csharp [ValueObject(MaxLength = 64, Comparison = StringComparison.OrdinalIgnoreCase)] public readonly partial struct PartnerReference; ``` `Comparison` drives the generated equality, ordering and hashing together, so `==`, `Equals`, `GetHashCode`, `CompareTo` and a dictionary lookup all agree. It is also why the type is a struct the generator owns rather than a `record struct`, whose field-wise equality would ignore it. The EF Core comparer follows the same rule, so change tracking agrees with the application. The database does not: give the column a case-insensitive collation, or a query and the application will disagree about which values are equal. `Comparison` applies to string value objects only. # Format a value By default a value object formats as its underlying value. An IBAN is stored in its electronic form, but people read it in groups of four, and logs should only show its last digits. A formatter hook adds named formats. ```csharp [ValueObject(MinLength = 15, MaxLength = 34)] public readonly partial struct Bban : IValueObjectFormatter { public static class Formats { public const string Electronic = "E"; public const string Masked = "M"; } public static bool TryFormatValue( in string value, Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { _ = provider; if (destination.Length < value.Length) { charsWritten = 0; return false; } value.CopyTo(destination); if (format is "M" or "m") { destination[2..(value.Length - 4)].Fill('*'); } charsWritten = value.Length; return true; } } ``` ```csharp skip var bban = Bban.Create("30006000011234567890189"); bban.ToString() // "30006000011234567890189" bban.ToString(Bban.Formats.Masked, null) // "30*****************0189" $"{bban:M}" // the same, through ISpanFormattable, with no intermediate string ``` ## The rules of the hook - **It takes over formatting entirely**, including the empty and `null` format. Handle the default case — here, anything but `M` writes the value as it is. - **Return `false` when the destination is too small.** The generated `ToString(format, provider)` grows its buffer and calls again; that is the framework contract, not an error. - The `Formats` class is a convention, not a requirement: named constants spare callers a magic letter. `IValueObjectFormatter` writes into a span and allocates nothing. For a rule whose output is naturally a `string`, `IValueObjectStringFormatter` takes `FormatValue(in value, format, provider)` instead; when a type declares both, the string formatter wins. Formatting never affects the wire: JSON, the database and model binding always carry the underlying value. # Conversions and arithmetic Both are opt-in, per type. A value object that converts silently in both directions is a primitive with extra steps; each option below loosens one thing, on purpose. ## Reading the value without `.Value` ```csharp [ValueObject(MaxLength = 254, ImplicitConversionToValue = true)] public readonly partial struct EmailAddress; ``` ```csharp skip string address = email; // no .Value ``` Reading stays terse while construction stays explicit. This is the conversion worth turning on for most types: passing a value object to an API that takes the underlying type is common, and cannot produce an invalid value. ## Constructing with a cast ```csharp [ValueObject(MaxLength = 254, ExplicitConversionFromValue = true)] public readonly partial struct EmailAddress; ``` ```csharp skip var email = (EmailAddress)text; // validates; throws ValueObjectException when rejected ``` The cast runs the same normalization and validation as `Create`, and throws as `Create` does. There is no implicit conversion from the underlying value: construction that can fail should be visible where it happens. ## Arithmetic on numeric value objects ```csharp [ValueObject(Arithmetic = true, Minimum = "0")] public readonly partial struct Amount : IValueObjectNormalizer { public static decimal NormalizeValue(decimal value) => decimal.Round(value, 2, MidpointRounding.ToEven); } ``` `Arithmetic = true` adds `+`, `-`, `*` and `/`, unary `-`, `Zero`, `One`, `IsZero`, `Min` and `Max`, and the `INumericValueObject` interface for generic code. **Every result goes back through `Create`.** `Amount.Zero - amount` throws rather than producing a negative amount the type forbids, and a product is rounded by the normalizer like any other value. Division of one value object by another returns the bare underlying type: a ratio of two amounts is not an amount. `Arithmetic` is available on numeric underlying types only; anywhere else it is `VO0007`. # Add domain behaviour The generator owns construction, conversion, equality and text. Everything else about the concept is yours, and lives in the same `partial` declaration. ## A factory of your own ```csharp [ValueObject] public readonly partial struct CustomerId : IValueObjectValidator { public static CustomerId New() => CreateUnchecked(Guid.CreateVersion7()); public static ValidationResult ValidateValue(in Guid value) => value == Guid.Empty ? ValidationResult.Required("A customer identifier must not be empty.") : ValidationResult.Success; } ``` `CreateUnchecked` skips normalization and validation. It is legitimate here because the application produced the value itself, and never for input that comes from outside — a request, a file, a message. ## Properties derived from the value ```csharp [ValueObject(MinLength = 15, MaxLength = 34, Pattern = "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$")] public readonly partial struct Iban { public string CountryCode => Value[..2]; } ``` `Value` is always normalized and valid on an instance that was constructed, so a derived property can rely on the rules: this one needs no length check, because `MinLength = 15` has run. ## What not to add - **A constructor.** The generator owns it, so that no path skips the rules. - **Fields.** A value object holds one value; a concept made of several belongs in an ordinary type that holds several value objects. - **Equality members.** The generated ones honour the declared `Comparison`; a hand-written `Equals` would not. Next: [Test your value objects](./test-value-objects.md), which checks the generated and the hand-written parts together. # Test your value objects The generated code is tested in this repository. Your rules are not: a normalizer that is not idempotent, or a pattern that rejects a value the validator was written to accept, is a bug in your type. The contract kit finds those from a short list of examples. ```bash dotnet add package AdCodicem.ValueObjects.Testing ``` The kit is built on xUnit v3. ## Declare a contract ```csharp skip public sealed class IbanContract : ValueObjectContract { protected override IEnumerable AcceptedValues => ["FR7630006000011234567890189", "DE89370400440532013000"]; protected override IEnumerable RejectedValues => ["", "not-an-iban", "FR7630006000011234567890188"]; } ``` Give at least two distinct accepted values, so ordering can be checked, and rejected values that exercise each rule — here an empty string, a wrong shape, and a wrong check digit. ## What it checks Each is an xUnit test in your suite: - every accepted value produces an initialized instance; - every rejected value is refused by `TryCreate` with a stable code and a message, and `Create` throws with that same code; - normalization settles after one pass, and creating from an already created value changes nothing; - equality is reflexive and symmetric, and agrees with the hash code; - ordering agrees with equality; - text survives a round trip, and formatting into a span matches formatting into a string; - JSON carries the bare underlying value, and rejects what the type rejects; - the type is discoverable at run time; - every accepted value respects the declared length limits. ## Constructing an invalid instance on purpose A test that needs an uninitialized instance — to check a guard, say — trips `VO0010`, which is a build error. Disable it on the spot, with a comment saying why: ```csharp skip #pragma warning disable VO0010 // The guard under test must reject an uninitialized instance. var missing = default(Iban); #pragma warning restore VO0010 ``` ## Identifiers in tests `New()` on an `[EntityId]` reads an ambient clock and entropy source. `ValueObjectIds.Use(clock, bytes)` pins both for the current execution flow, so a test gets the same identifier on every run — [Public identifiers](../tutorials/public-identifiers.md#test-with-them) shows it. # Work with a type known only at run time Domain code and the integrations use the typed path — `Iban.TryCreate`, or the static abstract members of `IValueObject` through a type parameter — which neither boxes nor allocates. Some code only has a `Type`: a generic importer, a tool that reads configuration, an integration of your own. That code goes through the registry, in `AdCodicem.ValueObjects.Metadata`. ```csharp skip if (ValueObjectRegistry.TryGet(type, out var descriptor) && descriptor.TryParse(text, CultureInfo.InvariantCulture, out var boxed, out var validation)) { // boxed is the value object, as object } ``` A descriptor exposes what the type declares and how to build one: - `ValueObjectType` and `ValueType`, the value object and its underlying type; - `Schema`, the declared rules — lengths, pattern, bounds, format, known values — as data; - `Create`, `TryCreate`, `CreateUnchecked` and `TryParse`, which take and return boxed values; - `GetValue` and `Format`, to read an instance back. A rejection carries the same `ValidationResult` as the typed path, with the code of the rule that fired. ## Cheaper questions `ValueObjectRegistry.IsValueObject(type)` and `ValueObjectRegistry.GetUnderlyingType(type)` answer without a descriptor. `TryResolve` also unwraps `Nullable`, which is what a model binder or a serializer is usually holding. ## When a type is not found Nothing needs registering by hand: every value object joins the registry through a generated module initializer. A module initializer only runs once its assembly is loaded, though, so code that looks a type up before anything else has touched that assembly can call `ValueObjectRegistry.EnsureAssemblyRegistered(assembly)` first. The EF Core and Dapper entry points already do. # Use with an AI coding agent Because the whole implementation is generated, a model that has never seen this library guesses its surface wrong: a hand-written factory, a `record struct`, a `JsonConverter` nobody needs, a rule that never runs because its interface was not declared. The repository ships an agent skill that states the surface precisely: the attribute options, the hook interfaces, the wiring of each integration, and every diagnostic with its fix. Every C# snippet in it is compiled by the generator's test suite, so it cannot drift away from what the generator accepts. ## Claude Code ``` /plugin marketplace add AdCodicem/AdCodicem.ValueObjects /plugin install adcodicem-valueobjects@adcodicem ``` The skill loads whenever a project references AdCodicem.ValueObjects, a primitive is being wrapped in a domain type, or a `VO00xx` diagnostic needs fixing. ## Other agents The skill is plain Markdown under [`skills/value-objects/`](https://github.com/AdCodicem/AdCodicem.ValueObjects/tree/main/skills/value-objects): point any agent at `SKILL.md` and its `references/` folder. ## For assistants that read documentation This site publishes [`llms.txt`](pathname:///llms.txt), an index of these pages for language models, and [`llms-full.txt`](pathname:///llms-full.txt), the documentation of the latest release in a single Markdown file. # Migrate from another library A migration can go one type at a time: a value object from this library sits next to a primitive or another library's type without either noticing. The wire format does not change either — every library on this page writes a single-value object as its bare underlying value — so API clients see nothing. After converting a type, point the [contract kit](./test-value-objects.md) at it with the values your old tests used: it checks that the new type accepts and rejects what the old one did, and round-trips through text and JSON the same way. ## From bare primitives This is the common case, and the one the library exists for. 1. **Declare the type**, moving the rules scattered through validators and controllers onto it: lengths and patterns on the attribute, the rest in `ValidateValue`, any trimming or upper-casing in `NormalizeValue`. 2. **Change the entity and the contracts**, property by property. JSON bodies, route segments and query strings keep the same shape. 3. **Map it in EF Core** with `ConfigureValueObjects`. Then read the next migration carefully: a `MaxLength` on the type now sizes the column, so a column that was unbounded becomes bounded. That is usually the point, but it is a schema change, and existing rows must fit. 4. **Delete what the type now guarantees**: the format checks in validators, the `Trim()` calls, the `[MaxLength]` and `[RegularExpression]` attributes, the OpenAPI annotations. For payloads that still carry raw text, [`MustParseAs`](./fluentvalidation.md) replaces them without restating the rules. ## From hand-written value objects Keep the rules, delete the plumbing. - Move the checks into `NormalizeValue` and `ValidateValue`, and turn each thrown exception into a returned `ValidationResult` with a code of your own. - Delete the constructor, `Equals`, `GetHashCode`, the operators, `ToString`, `Parse`, and every hand-written `JsonConverter`, `TypeConverter`, EF Core converter and model binder: the generator writes all of them. - A `record struct` or a class becomes a `readonly partial struct`. Where a class could be `null`, use `T?`. ## From Vogen | Vogen | AdCodicem.ValueObjects | | --- | --- | | `[ValueObject] partial class` or `struct` | `[ValueObject] readonly partial struct` | | `private static string NormalizeInput(string input)` | `public static string NormalizeValue(string value)`, with `IValueObjectNormalizer` | | `private static Validation Validate(string input)` | `public static ValidationResult ValidateValue(in string value)`, with `IValueObjectValidator` | | `Validation.Ok` / `Validation.Invalid("…")` | `ValidationResult.Success` / `ValidationResult.Failure("code", "…")` | | `Iban.From(raw)` | `Iban.Create(raw)` | | `Iban.TryFrom(raw, out var iban)` | `Iban.TryCreate(raw, out var iban)` | | `ValueObjectOrError result = Iban.TryFrom(raw)` | `Iban.TryCreate(raw, out var iban, out var validation)` | | `ValueObjectValidationException` | `ValueObjectException`, which also carries `ErrorCode` | | Explicit casts, both ways by default | Opt in with `ExplicitConversionFromValue` and `ImplicitConversionToValue` | | `Conversions.EfCoreValueConverter`, `HasVogenConversion()` | `ConfigureValueObjects(assembly)`, once | | `Conversions.DapperTypeHandler` | `ValueObjectDapper.AddValueObjectHandlers(assembly)`, once | | `Conversions.NewtonsoftJson` | `ValueObjectConverter` in the serializer settings | | `new VogenTypesFactory()` in the options of a source-generated context | `[JsonSourceGenerationOptions(Converters = [typeof(ValueObjectJsonConverterFactory)])]` | | A length or pattern check inside `Validate` | `MinLength`, `MaxLength`, `Pattern` on the attribute | Side by side, a normalized and validated IBAN: ```csharp skip // Vogen [ValueObject(conversions: Conversions.Default | Conversions.EfCoreValueConverter)] public partial class Iban { private static string NormalizeInput(string input) => input.Replace(" ", "").ToUpperInvariant(); private static Validation Validate(string input) => input.Length is >= 15 and <= 34 ? Validation.Ok : Validation.Invalid("An IBAN has between 15 and 34 characters."); } ``` ```csharp // AdCodicem.ValueObjects [ValueObject(MinLength = 15, MaxLength = 34)] public readonly partial struct Iban : IValueObjectNormalizer { public static string NormalizeValue(string value) => value.Replace(" ", "").ToUpperInvariant(); } ``` The length check needs no code any more, and it now sizes the column and documents the schema too. Three differences to plan for: - **EF Core reads.** Vogen validates values read from the database by default; this library does not. If other systems write to your tables, keep that behaviour with `ConfigureValueObjects(strict: true, …)`. - **Instances.** A Vogen instance may deliberately hold a value that `Validate` would refuse, as a sentinel. There is no such escape hatch here: express absence as `Iban?`, and name valid values with [`[KnownValue]`](../tutorials/known-values.md). - **Underlying types.** Vogen wraps any type; this library supports 22. A value object over a `Uri` or a type of your own has no direct equivalent. ## From StronglyTypedId | StronglyTypedId | AdCodicem.ValueObjects | | --- | --- | | `[StronglyTypedId] partial struct OrderId` (a `Guid`) | `[ValueObject] readonly partial struct OrderId` | | `[StronglyTypedId(Template.String)]` | `[ValueObject]` | | `new OrderId(guid)` | `OrderId.Create(guid)`: the constructor is private, so the rules cannot be skipped | | `OrderId.New()` | A `New()` of your own, below — or an [`[EntityId]`](../tutorials/public-identifiers.md) for an identifier clients see | | `OrderId.Empty` | `OrderId?` for absence; `default(OrderId)` is now a build error | | `new OrderId.EfCoreValueConverter()`, one per type | `ConfigureValueObjects(assembly)`, once | | `SqlMapper.AddTypeHandler(new OrderId.DapperTypeHandler())`, one per type | `ValueObjectDapper.AddValueObjectHandlers(assembly)`, once | | Converters passed to a source-generated context's options | `[JsonSourceGenerationOptions(Converters = [typeof(ValueObjectJsonConverterFactory)])]` | ```csharp [ValueObject] public readonly partial struct OrderId : IValueObjectValidator { public static OrderId New() => CreateUnchecked(Guid.CreateVersion7()); public static ValidationResult ValidateValue(in Guid value) => value == Guid.Empty ? ValidationResult.Required("An order identifier must not be empty.") : ValidationResult.Success; } ``` The validator is new: StronglyTypedId has no validation, so an empty `Guid` was a valid identifier. Once the types are converted, remove the `StronglyTypedId` and `StronglyTypedId.Templates` packages and any `.typedid` templates. ## From Thinktecture.Runtime.Extensions - **The validation hook splits in two.** `ValidateFactoryArguments(ref ValidationError? validationError, ref string value)` both normalizes, by assigning `value`, and validates. Move the assignment into `NormalizeValue` and the checks into `ValidateValue`, returning a `ValidationResult` instead of setting an error. - **String comparison changes.** Thinktecture compares string keys case-insensitively by default; this library compares ordinally. To keep the behaviour, declare `Comparison = StringComparison.OrdinalIgnoreCase` — or, better, [normalize the case](./string-comparison.md) so the stored value is canonical. - **A class becomes a `readonly partial struct`.** `null` checks become `T?`. - **EF Core.** `UseThinktectureValueConverters()` becomes `ConfigureValueObjects(assembly)` in `ConfigureConventions`. Both skip validation on read by default. - **Smart enums and unions stay where they are.** Only single-value value objects have an equivalent here; a closed set of codes can become a value object with [known values](../tutorials/known-values.md). # Packages Twelve NuGet packages. Install `AdCodicem.ValueObjects` and add whichever of the others cover the boundaries your application actually has. | Package | What it gives you | | --- | --- | | **`AdCodicem.ValueObjects`** | The one to install: contracts, source generator and analyzers. | | `AdCodicem.ValueObjects.Abstractions` | The contracts alone, with no dependency at all. | | `AdCodicem.ValueObjects.Json` | Covers source-generated serializer contexts and hand-written value objects. | | `AdCodicem.ValueObjects.EntityFrameworkCore` | Converters, comparers, and a convention that maps a whole assembly. | | `AdCodicem.ValueObjects.AspNetCore` | MVC model binding and RFC 9457 problem details carrying the violated rule. | | `AdCodicem.ValueObjects.OpenApi` | Schema transformer for the built-in .NET OpenAPI stack. | | `AdCodicem.ValueObjects.FluentValidation` | Rules that reuse what the value object already enforces. | | `AdCodicem.ValueObjects.Dapper` | Type handlers for raw SQL. | | `AdCodicem.ValueObjects.NewtonsoftJson` | Interop with code that has not moved to `System.Text.Json`. | | `AdCodicem.ValueObjects.Identifiers` | Stripe-style public entity identifiers: `acc_2K7X9…`. See [Entity Identifiers](./entity-identifiers.md). | | `AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore` | Fixed-width, non-Unicode columns for those identifiers. | | `AdCodicem.ValueObjects.Testing` | An xUnit contract kit for your own value objects. | ## Why this many packages Each integration is its own package so that adding EF Core support doesn't pull FluentValidation into a service that has no use for it, and so that a trimmed or AOT-published app only carries the generator's output for the boundaries it actually crosses. `AdCodicem.ValueObjects` is the only package with a source generator in it; everything else is a thin, generic-closed integration over the contracts in `Abstractions`. # Authoring reference ## Supported underlying types `string`, `Guid`, `bool`, `char`, every built-in integer (including `Int128` and `UInt128`, which travel as JSON strings), `decimal`, `double`, `float`, `DateOnly`, `TimeOnly`, `DateTime`, `DateTimeOffset`, `TimeSpan`. ## Declarative options on `[ValueObject]` | Option | Type | Default | Effect | | --- | --- | --- | --- | | `Pattern` | `string?` | none | Regular expression the **normalized** value must match. Also the OpenAPI `pattern`. Invalid → `VO0014`. | | `MinLength`, `MaxLength` | `int` | `-1`, unconstrained | `string` only (`VO0008` otherwise). Validation, OpenAPI `minLength` / `maxLength`, and the EF Core column size. | | `Minimum`, `Maximum` | `string?` | none | Inclusive bounds in **invariant-culture text**, so `decimal`, `DateOnly` and `TimeSpan` keep full precision. Parsed at compile time; unparsable → `VO0004`. Also OpenAPI `minimum` / `maximum`. | | `Comparison` | `StringComparison` | `Ordinal` | `string` only. Drives equality, ordering and hashing together. | | `ValueSet` | `ValueSetKind` | `Open` | `Closed` accepts only the declared `[KnownValue]`s, through a frozen lookup, and becomes the schema `enum`. Members of a closed set over a reference type are boxed once and shared, so the boxed paths allocate nothing. | | `Arithmetic` | `bool` | `false` | Numeric types only (`VO0007` otherwise). Operators and generic math; every result is validated again. | | `ImplicitConversionToValue` | `bool` | `false` | `string s = iban;` | | `ExplicitConversionFromValue` | `bool` | `false` | `(Iban)text`, validating like `Create`. | | `AllowEmpty` | `bool` | `false` | `string` only. Accepts `""`; `null` is still rejected, since absence is `T?`. | | `AllowDefault` | `bool` | `false` | Silences `VO0010`, for a type whose zero state is meaningful. | | `SchemaFormat` | `string?` | the natural format of the type | OpenAPI `format`: `uuid`, `date`, `int64`, or your own such as `iban` or `email`. | | `Example` | `string?` | none | OpenAPI example. | | `Description` | `string?` | the type's XML `` | OpenAPI description. | Declared rules run before any hook, so a validator only ever sees values that already satisfy them. [Validation and normalization](./tutorials/validation-and-normalization.md#the-order-things-run-in) gives the exact order. ## Hooks A value object declares a rule by implementing an interface, so the compiler checks the signature: a mis-typed rule fails the build instead of being silently ignored. All are optional, and `VO0011` reports a rule written without its interface — the one mistake the compiler cannot catch. | Interface | Member | | --- | --- | | `IValueObjectNormalizer` | `static TValue NormalizeValue(TValue value)` | | `IValueObjectSpanNormalizer` | `static string NormalizeValue(ReadOnlySpan value)` — string value objects only | | `IValueObjectValidator` | `static ValidationResult ValidateValue(in TValue value)` | | `IValueObjectFormatter` | `static bool TryFormatValue(in TValue value, Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider)` | | `IValueObjectStringFormatter` | `static string FormatValue(in TValue value, ReadOnlySpan format, IFormatProvider? provider)` | `NormalizeValue` must be idempotent and must not reject: an unnormalizable value is rejected by `ValidateValue`. `TryFormatValue`, when present, takes over formatting entirely, including the default format. Adding `IValueObjectSpanNormalizer` alongside `IValueObjectNormalizer` lets parsing and JSON reading normalize straight from the text, so ingesting a value allocates the normalized string and nothing else. It halves what `TryParse` allocates, and makes deserializing a payload of value objects allocate exactly what deserializing the same payload of primitives does. Write the value-typed overload as a one-line delegation: ```csharp public readonly partial struct Iban : IValueObjectNormalizer, IValueObjectSpanNormalizer { public static string NormalizeValue(string value) => NormalizeValue(value.AsSpan()); public static string NormalizeValue(ReadOnlySpan value) { Span buffer = value.Length <= 64 ? stackalloc char[64] : new char[value.Length]; // ... write the normalized characters into buffer ... return new string(buffer[..length]); } } ``` The rules are public because a static interface member cannot be anything else. `Normalize` remains the member callers use: it guards against a null underlying value and then defers to `NormalizeValue`. ## Diagnostics The generator and the analyzers report `VO0001` to `VO0018`. [Diagnostics](./reference/diagnostics.md) lists each one with its fix. Next: [Generated members](./reference/generated-members.md), for what the generator writes from all of this. # Generated members What the generator adds to a `readonly partial struct` marked `[ValueObject]`. `TSelf` is the value object, `TValue` its underlying type. ## Interfaces Every value object implements `IValueObject`, which brings: - `IEquatable`, `IComparable` and `IComparable`; - `ISpanParsable`, and so `IParsable`; - `ISpanFormattable`, and so `IFormattable`. `Arithmetic = true` adds `INumericValueObject`; `[EntityId]` adds `IEntityId`. ## State | Member | | | --- | --- | | `TValue Value` | The underlying value, normalized and valid on any constructed instance. For a `string` value object, an uninitialized instance reads as `""`. | | `bool IsDefault` | `true` for an instance that was never constructed. The run-time guard where `VO0010` cannot see. | | `static ValueObjectSchema Schema` | The declared rules as data: lengths, pattern, bounds, format, known values, description. | ## Construction | Member | | | --- | --- | | `static TSelf Create(TValue value)` | Normalizes, validates, and throws `ValueObjectException` on rejection. | | `static bool TryCreate(TValue value, out TSelf result)` | The same, returning `false` instead of throwing. | | `static bool TryCreate(TValue value, out TSelf result, out ValidationResult validation)` | The same, with the rule that fired. | | `static TSelf CreateUnchecked(TValue value)` | Skips normalization and validation, for values the application produced itself. | | `static TValue Normalize(TValue value)` | Runs `NormalizeValue` if the type declares it; passes `null` through. | | `static ValidationResult Validate(in TValue value)` | The declared rules, then `ValidateValue` if the type declares it. | The constructor is private: every public way in goes through `Create`, `TryCreate`, `Parse`, `TryParse` or `CreateUnchecked`. ## Text | Member | | | --- | --- | | `static TSelf Parse(string s)`, `Parse(string s, IFormatProvider? provider)`, `Parse(ReadOnlySpan s, IFormatProvider? provider)` | Parses the underlying value from text, then as `Create`. | | `static bool TryParse(…, out TSelf result)` | For `string` and `ReadOnlySpan`, with or without a provider. | | `static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out TSelf result, out ValidationResult validation)` | And with the rule that fired; a `string` converts to the span implicitly. | | `string ToString()`, `ToString(string? format, IFormatProvider? provider)` | The underlying value, or the named [formats](../how-to/formatting.md) of a formatter hook. | | `bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider)` | Formats without allocating. | Text that does not even have the shape of the underlying type is rejected with `value_object.not_parsable`, before any rule of the type runs. ## Equality and ordering `Equals(TSelf)`, `Equals(object?)`, `GetHashCode()`, `CompareTo(TSelf)`, and the operators `==`, `!=`, `<`, `>`, `<=`, `>=`. For a `string` value object they all follow the declared `Comparison`, ordinal by default. ## Serialization and discovery - A nested `ValueJsonConverter`, applied with `[JsonConverter]`: the value is read and written as its bare underlying value, including as a dictionary key. - A nested `ValueTypeConverter`, applied with `[TypeConverter]`, converting from and to the underlying value and its text. - `[DebuggerDisplay]`, showing the formatted value. - A registration in a generated `[ModuleInitializer]`, which makes the type available to [`ValueObjectRegistry`](../how-to/runtime-lookup.md) without any code of yours. ## Added by options | Option | Adds | | --- | --- | | `[KnownValue("Name", …)]` | `static TSelf Name`, per value, and `static ImmutableArray KnownValues`. | | `ImplicitConversionToValue = true` | `implicit operator TValue(TSelf)`. | | `ExplicitConversionFromValue = true` | `explicit operator TSelf(TValue)`, validating like `Create`. | | `Arithmetic = true` | `+`, `-`, `*`, `/`, unary `-`, `Zero`, `One`, `IsZero`, `Min`, `Max`. | | `[EntityId("prefix")]` | `New()`, `Prefix`, `Granularity`, `Length`. | # Error codes and exceptions A rejected value always carries two things: a stable, machine-readable **code**, and a human-readable **message**. The code is the contract — it reaches problem details responses and FluentValidation failures, and a client may branch on it. The message is for people, and may change. ## Framework codes Defined as constants on `ValueObjectErrorCodes`: | Code | Raised when | | --- | --- | | `value_object.required` | The value is `null`, or an empty string on a type without `AllowEmpty`. | | `value_object.too_short` | A string is shorter than `MinLength`. | | `value_object.too_long` | A string is longer than `MaxLength`. | | `value_object.invalid_format` | The value does not match `Pattern`; also the code of `ValidationResult.InvalidFormat`. | | `value_object.out_of_range` | The value is below `Minimum` or above `Maximum`; also the code of `ValidationResult.OutOfRange`. | | `value_object.not_a_known_value` | The value is not one of the known values of a closed set. | | `value_object.not_parsable` | The text does not even have the shape of the underlying type, so no rule of the type ran. | ## Codes of your own A validator hook returns `ValidationResult.Failure("delivery_date.sunday", "Nothing is delivered on a Sunday.")` for a rule of its own. Prefer a specific code of your own over a framework code that means something else: a client can act on `delivery_date.sunday`, not on a generic `value_object.invalid_format`. ## `ValidationResult` A `readonly struct` returned by `Validate`, by `TryCreate` and `TryParse` through their `out` parameter, and by validator hooks. | Member | | | --- | --- | | `static ValidationResult Success` | The success state, which is `default`: accepting a value allocates nothing. | | `bool IsValid` | `true` on success. | | `string? ErrorCode`, `string? ErrorMessage` | The code and message of the rule that fired, `null` on success. | | `static ValidationResult Failure(string errorCode, string errorMessage)` | A rejection with a code of your own. | | `Required`, `InvalidFormat`, `OutOfRange` | Rejections with the framework code and an optional message. | Validation is fail-fast: a result carries one reason, the first rule that failed. ## `ValueObjectException` Thrown by `Create`, by `Parse`, and by an explicit conversion, when the value is rejected. It carries: | Member | | | --- | --- | | `ErrorCode` | The code of the violated rule, the same `TryCreate` would have reported. | | `ValueObjectType` | The value object that refused the value. | | `AttemptedValue` | The value as it was passed in, before normalization. | | `Message` | The message of the violated rule. | Every integration on a boundary — JSON, model binding, EF Core, Dapper — uses `TryCreate` instead, so the exception is reserved for code that treats a rejected value as a bug. ## Detecting an uninitialized instance `IsDefault` is `true` for an instance that was never constructed: one that crossed a boundary the `VO0010` analyzer cannot see, such as a default array element or another library's deserializer. FluentValidation's [`NotDefault`](../how-to/fluentvalidation.md#an-uninitialized-value-object) rule checks it at the edge. # Diagnostics Every rule the generator and the analyzers enforce. `VO0008` and `VO0011` are warnings; every other one is an error. There is no `VO0012`. | Id | Meaning | Fix | | --- | --- | --- | | `VO0001` | The type is not `partial`. | Add `partial`: the generated members are added to the same type. | | `VO0002` | The type is not a `readonly struct`, or is a record. | Declare a `readonly partial struct`. A `record struct` is refused on purpose, because `with` and field-wise equality would bypass validation and the declared comparison; a class, because a value object is a value. | | `VO0003` | Unsupported underlying type. | Use one of the [supported types](../authoring-guide.md#supported-underlying-types). | | `VO0004` | A bound could not be parsed. | Write `Minimum` and `Maximum` as invariant-culture text: `"0"`, `"9.99"`, `"2020-01-01"`. | | `VO0005` | A closed value set declares no value. | Add `[KnownValue]` entries, or drop `ValueSet = ValueSetKind.Closed`: as declared, no value could be valid. | | `VO0006` | A known value has an unusable name. | The first argument of `[KnownValue]` becomes a member: give it a valid, unique C# identifier. | | `VO0007` | Arithmetic requested on a non-numeric type. | Remove `Arithmetic = true`, or change the underlying type. | | `VO0008` | Length constraints on a non-string type (warning). | `MinLength` and `MaxLength` apply to `string` only; use `Minimum` and `Maximum` for a number. | | `VO0009` | A containing type is not `partial`. | Every enclosing type must be `partial`, not only the value object. | | `VO0010` | An uninitialized value object: `default(T)` or `new T()`. | Construct through `Create`, `TryCreate` or `Parse`, and express absence as `T?`. If the zero state is genuinely meaningful, set `AllowDefault = true` on the type. | | `VO0011` | A rule written without its hook interface (warning). | Declare the interface — `IValueObjectNormalizer`, `IValueObjectValidator`, `IValueObjectFormatter`, `IValueObjectStringFormatter`. Until then the rule never runs. | | `VO0013` | A known value could not be converted. | A `Guid`, a `decimal` or a `DateOnly` is written as invariant-culture text and converted at compile time; fix the text. | | `VO0014` | An invalid regular expression. | Fix `Pattern`. A verbatim string (`@"^\d+$"`) keeps escapes intact. | | `VO0015` | A malformed entity identifier prefix. | One or more lower-case segments separated by `_`, each starting with a letter: `"acc"`, `"sk_live"`. | | `VO0016` | Two types claim the same prefix. | Give each identifier type its own prefix, or one kind of identifier would parse as another. | | `VO0017` | A normalization hook on an entity identifier. | `[EntityId]` normalizes its own format and would never call the hook: remove it. | | `VO0018` | Both `[EntityId]` and `[ValueObject]` on one type. | Each generates a whole implementation; keep the one that describes the type. | `VO0011` deserves its warning more than most. The code it reports compiles and looks right, and in a project without `TreatWarningsAsErrors` it ships with the rule silently absent. It also recognizes the names hooks had before they became interfaces — `NormalizeCore`, `ValidateCore`, `TryFormatCore`, `FormatCore` — which should be renamed to `NormalizeValue`, `ValidateValue`, `TryFormatValue` and `FormatValue`. ## Suppressing `VO0010` in a test A test that must build an uninitialized instance disables the diagnostic on the spot, with the reason: ```csharp skip #pragma warning disable VO0010 // The guard under test must reject an uninitialized instance. var missing = default(Iban); #pragma warning restore VO0010 ``` # Primitive obsession Primitive obsession is the habit of carrying domain concepts as the language's built-in types: an IBAN as a `string`, a quantity as an `int`, a customer identifier as a `Guid`. Each one is harmless where it is written. The cost shows up between the places where it is used. ## What it costs **The compiler cannot help.** `Task PayAsync(string customerId, string iban, decimal amount)` accepts its first two arguments in either order, and accepts `"hello"` for both. A `Guid` identifying a customer and a `Guid` identifying an order are the same type, so a repository asked for one happily receives the other. **Every layer states the rules again.** The controller checks the format, the domain checks it once more because it cannot trust the controller, a migration picks a column width, and the OpenAPI document settles for `type: string`. Four statements of one rule, written by different people at different times, and nothing fails when they disagree. **Normalization happens by luck.** `" fr76 3000 6000 …"` and `"FR7630006000…"` are the same account. Whether the application treats them as the same depends on whether this particular code path remembered to trim and upper-case — and so do equality, unique indexes and deduplication. **The signature says nothing.** A `string` parameter carries no information about what it accepts. That knowledge lives in validators, comments, and the heads of the people who wrote them. ## The remedy, and why it is rarely applied The remedy is old and uncontroversial: give each concept its own type, one that cannot hold an invalid value. Domain-driven design calls it a value object. Once `Iban` exists, swapped arguments stop compiling, the rules live in one place, and a method that receives an `Iban` has nothing left to check. It stays rare because the type is only the beginning. A value object that crosses the boundaries of a real application also needs equality and hashing that agree with its comparison rules, parsing and formatting, a `System.Text.Json` converter, a `TypeConverter`, an EF Core value converter and comparer, a model binder, and a schema in the OpenAPI document. Written by hand, that is a few hundred lines per concept, most of them the same from one type to the next. At that price a team writes three value objects and goes back to `string` for the rest. ## What changes here AdCodicem.ValueObjects moves that cost to the compiler. A `readonly partial struct` marked `[ValueObject]` declares the concept and its rules; a source generator writes the implementation, and the integration packages carry the same rules to each boundary: | Where the rule is needed | Where it comes from | | --- | --- | | Construction, parsing, deserialization, model binding | The generated `Validate`, always run after `Normalize` | | The database column | `MaxLength`, applied by the EF Core convention | | The OpenAPI document | `MaxLength`, `Pattern`, `Minimum`, known values, turned into schema keywords | | An API error response | The stable error code of the rule that was violated | On the wire nothing changes: an `Iban` is still a JSON string, a route segment and a query-string parameter, and in the database it is still a `varchar`. Clients and schemas see the underlying type; only the code sees the concept. ## When not to bother A value that never leaves one method, or that the domain genuinely treats as free text — a comment, a display label — gains little from a type of its own. The concepts worth wrapping are the ones that carry rules, cross boundaries, or get confused with one another: identifiers, codes, amounts, addresses, anything with a format. Next: [Getting started](../getting-started.md), or [Design decisions](../design-decisions.md) for why the type is a struct and why `default` is a build error. # 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 twice 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](./benchmarks.md) 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, and every integration — JSON, model binding, EF Core, Dapper — goes through `TryCreate`. `Create` throws, and is for the call sites that want it. 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 reason `AdCodicem.ValueObjects.Json` exists: a hand-written converter factory the STJ generator *can* see. The same constraint is why `Pattern` compiles a `Regex` at runtime rather than using `[GeneratedRegex]`. - **Generated code cannot rely on the consumer's usings.** Every type and extension method is fully qualified in emitted code. A consumer with `ImplicitUsings` disabled would otherwise get a compile error in code they cannot edit. - **`static virtual` and `static abstract` interface 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](./entity-identifiers.md), which build on the same generator for a different shape of value. # Entity identifiers Stripe-style public identifiers — `acc_1kcv3ahrz6dmv29gqy5cv` — as value objects, from `AdCodicem.ValueObjects.Identifiers`. This page records the design and the reasoning behind it. ## What the type is for An entity identifier is the identity of an aggregate as the outside world sees it: it appears in URLs, in JSON payloads, in webhook bodies, in support tickets and in the database. It is *not* a secret, and nothing about it may be relied upon for authorization. The prefix is the load-bearing part of the format. `cus_…` cannot be parsed as an `AccountId`, so substituting one identifier for another in a request parameter fails at the boundary rather than reaching a repository. That is the reason the prefix is stored in the database rather than reconstructed at read time: a raw-SQL join between two tables holding bare bodies would succeed silently, whereas prefixed values cannot be confused even by a query the application never sees. ## Format ``` acc_ TTTT RRRRRRRRRRRRRRRR C │ │ │ │ │ │ │ └─ 1 check character │ │ └─ 16 characters = 80 bits from a CSPRNG │ └─ time bucket, width derived from the granularity └─ prefix, one or more lowercase segments ``` The alphabet is Crockford Base32 in lower case — `0123456789abcdefghjkmnpqrstvwxyz` — chosen over Base62 for three reasons: 1. **It is order-preserving under ordinal comparison.** The alphabet is strictly increasing in ASCII, so comparing two identifiers as text reproduces the numeric order of their encoded values. The time bucket sits at the head of the body, which makes the B-tree ordering of the column chronological with no extra work and with `StringComparison.Ordinal`, already this library's default. 2. **It removes the collation trap.** Normalization folds to lower case and maps Crockford's aliases (`i`, `l` → `1`; `o` → `0`), so the stored value is canonical. A case-insensitive column collation can no longer collapse two distinct identifiers. `Latin1_General_BIN2` / `COLLATE "C"` remains preferable for speed, but is no longer a correctness requirement. 3. **It survives being read aloud.** No `l`/`i`/`o`/`0` confusion in a support ticket. Lower case is the canonical spelling, so an identifier is one unbroken token from prefix to check character — `acc_1kcv3ahrz6dmv29gqy5cv`, not a lowercase prefix bolted onto a shouting body. Upper case still parses and folds down, so nothing that quotes an identifier back in the wrong case is turned away. **Crockford's optional hyphen is not accepted.** The specification allows one anywhere in an encoded value for readability, and grouping helps someone reading digits off a page. These identifiers are never transcribed by hand, so the grouped spelling is one nobody produces — and accepting it would mean two texts mapping onto one identifier, in a format whose whole discipline is that there is exactly one. `Normalize` therefore drops nothing: a hyphenated candidate keeps its length and is refused as `invalid_length`. A mixed-case alphabet would shorten an identifier by two characters. Why that is not worth taking is set out in [Rejected: a mixed-case alphabet](#rejected-a-mixed-case-alphabet). ### Widths The epoch is **2020-01-01T00:00:00Z**. Bucket width follows the granularity, sized to keep roughly a century of horizon: | Granularity | Bucket chars | Bits | Horizon | Body | Example total for `acc_` | | --- | --- | --- | --- | --- | --- | | `Minute` | 6 | 30 | year 4062 | 23 | `char(27)` | | `Hour` (default) | 4 | 20 | year 2139 | 21 | `char(25)` | | `Day` | 3 | 15 | year 2109 | 20 | `char(24)` | The random part is 80 bits at every granularity, giving a birthday bound of ≈ 1.1 × 10¹² identifiers *per bucket*. **Per bucket is the number that matters, and it is why this is 80 bits rather than 128.** Randomness is redrawn on every bucket, so what has to stay out of reach is the mint rate within one bucket width — and the bucket is deliberately sized at 10⁴–10⁵ rows, seven to eight orders of magnitude below the bound. One without a time bucket would have to budget against the lifetime row count of the table and would need its 128 bits; this one does not, and spending them would buy nothing but five characters on every row and every foreign key. Length is fixed per type, so the column is `char(n)`, not `varchar(n)`. ### The check character ``` check = ( seed(prefix) + Σᵢ wᵢ · vᵢ ) mod 32, wᵢ = 2·(i mod 16) + 1 ``` where `vᵢ` is the Crockford value of the i-th data character of the body (time bucket and random part, check character excluded) and `seed(prefix)` is a folded FNV-1a hash of the prefix reduced to five bits. The guarantee, stated exactly because a checksum that promises more than it delivers is worse than none: - **every single-character substitution in the body is detected.** The weights are odd, hence invertible modulo 32, and a non-zero difference of Crockford values can never be ≡ 0 (mod 32); - **every adjacent transposition is detected** unless the two characters' values differ by exactly 16. The weight difference between adjacent positions is ≡ 2 (mod 32) everywhere, wrap included; - **changing the prefix changes the expected check character** for 31 prefixes out of 32, so a body copied between two identifier types is caught even by a validator that does not know which prefix to expect — which is what `AnyEntityId` needs before it has resolved anything; - random corruption slips through with probability 1/32. That is the information-theoretic limit of one check character over 32 symbols, and no scheme does better. ## Why a monotonic prefix, and what it costs A purely random primary key inserts into the middle of a B-tree. On SQL Server the primary key is clustered by default, so the table itself fragments; on PostgreSQL the heap is unordered but the index still touches random leaf pages on every insert, so the dirty working set is the whole index rather than its tail. Putting a coarse time bucket at the head of the body clusters insertions at the right edge of the tree. The security cost is bounded and explicit: - **enumeration is unaffected.** The random part keeps its full 80 bits. An attacker who knows the exact creation instant still faces 2⁸⁰. Conflating "partially time-derived" with "partially guessable" is the usual mistake; it does not apply here; - **the leak is temporal only**, at exactly the granularity chosen. An hourly bucket reveals the hour and nothing finer. Identifiers within one bucket are unordered relative to each other, so holding a handful of them does not yield a creation rate. **Choose the granularity from the insert rate, not from taste.** Aim for a bucket holding roughly 10⁴–10⁵ rows: at ~200 keys per leaf page that is a few hundred pages, which stays cached. Too wide a bucket and writes scatter again; too narrow and the identifier leaks more precisely than it needs to. Two complementary knobs, neither of which this library sets for you: - on SQL Server, `PRIMARY KEY NONCLUSTERED` confines fragmentation to the ~25-byte index rather than the whole row; - with a monotonic head, insertions are effectively append-only, so `FILLFACTOR` can go back up towards 95–100. ## Rejected: a mixed-case alphabet Base58 or Base62 would encode the same 80 bits in 14 characters instead of 16, taking `acc_` from 25 down to 23. Base64url encodes no shorter than either at these widths and would collide with both separators — `_` opens the body, `-` is Crockford's readability separator — so it was never in the running. Two characters do not pay for what they cost: - **the check character loses its proof.** The weights `2·(i mod 16) + 1` run 1, 3, …, 31. Modulo 58 the weight 29 is not invertible, so a substitution differing by two at that position slips through undetected; modulo 62 the weight 31 fails the same way. Each alphabet would need its own weight sequence and its own restated transposition bound; - **the encoder loses its uniformity for free.** 32 divides 256, so the low five bits of a uniform byte are a uniform symbol. Neither 58 nor 62 divides anything convenient, which forces either rejection sampling — an unbounded draw against a fixed `stackalloc` — or `UInt128` division; - **the collation stops being a performance choice.** Everything above rests on normalization folding an identifier to a single canonical spelling. A case-sensitive alphabet cannot fold, so a case-insensitive column collation — the default on SQL Server — can once again return the wrong row, and nothing enforces the binary collation for raw SQL, a second application, or a database restored elsewhere. It was considered as a per-type option too, and rejected on a sharper line. `Granularity` trades index locality against temporal leakage and every setting of it is correct. An alphabet switch is not that kind of knob: it would make correctness depend on an ambient database setting the library cannot verify, chosen per type, and baked into every persisted row. ## Rejected: the two-key pattern An internal sequential surrogate key alongside the public identifier was considered and dropped. It is only worth its cost when foreign keys carry the internal key — and then every serialization of a referencing entity needs a join to materialize the public identifier, trading write locality for read amplification, while adding an enumerable value that must never leak. With foreign keys carrying the public identifier, the internal key would fund nothing but the physical ordering of its own table, which the time bucket already delivers for free. One identity, visible everywhere. ## Authoring surface ```csharp [EntityId("acc")] public readonly partial struct AccountId; [EntityId("evt", Granularity = IdGranularity.Minute)] public readonly partial struct EventId; ``` Everything `[ValueObject]` generates is generated here too — construction, parsing, formatting, equality, ordering, the JSON converter, the `TypeConverter`, the registry entry — plus: | Member | Purpose | | --- | --- | | `AccountId.New()` | A fresh identifier from the ambient clock and entropy source. | | `AccountId.New(TimeProvider, IdEntropySource)` | The same, with both sources supplied explicitly. | | `AccountId.Prefix` | The declared prefix. | | `AccountId.Granularity` | The declared granularity. | `MinLength`, `MaxLength` and the OpenAPI `pattern` are **derived** from the profile and flow into the EF Core column and the OpenAPI schema exactly as they do for any other value object — the rule is still declared once. The pattern is published as schema text but never compiled: at fixed length over a fixed alphabet, validation is a span scan, so an entity identifier costs no `Regex` at start-up, unlike a `Pattern`-constrained value object. The generated `Normalize` trims surrounding whitespace, folds the body to lower case, applies the alias mapping, and canonicalizes the prefix's case. It drops nothing, so it preserves length. It never rejects either: a text without the expected prefix comes back unchanged and is refused by `Validate`. ### Error codes Rejections carry a code precise enough to act on, rather than a generic `not_parsable`: | Code | Meaning | | --- | --- | | `value_object.id.invalid_length` | Wrong total length for the declared profile. | | `value_object.id.invalid_prefix` | The text does not carry the declared prefix. | | `value_object.id.invalid_character` | A character outside the Crockford alphabet. | | `value_object.id.invalid_checksum` | The check character does not match. | ## The ambient provider `New()` reads a `TimeProvider` and an `IdEntropySource`. Both resolve through `ValueObjectIds`, which layers an `AsyncLocal` scope over a process-wide default: ```csharp skip ValueObjectIds.Configure(timeProvider, entropy); // once, at start-up using (ValueObjectIds.Use(fakeClock, deterministicBytes)) // scoped, wins over the default { var id = AccountId.New(); } ``` The scope exists because the test suites run in parallel. A settable static alone would let two tests racing to substitute the clock corrupt each other, and the symptom would be an intermittent failure — the most expensive kind to diagnose. The scope is bounded by the execution flow, so parallel tests do not interfere and no test collection has to be serialized. `IdEntropySource.System` wraps `RandomNumberGenerator`. It is a CSPRNG, deliberately: `Random.Shared` would make identifiers predictable from a handful of samples. ## Polymorphic references `AnyEntityId` parses any registered prefix and reports which type it belongs to. It serves webhooks, deep links, audit logs and heterogeneous references. It deliberately does **not** implement `IValueObject`, which is what makes it non-persistable by construction: the EF Core convention keys off that interface, so `AnyEntityId` is invisible to it and no one can accidentally map a polymorphic column. It is a transport and resolution type, nothing more. Its OpenAPI schema is a plain `string` with a description. A `oneOf` over every registered pattern would be faithful and unreadable, growing with every identifier type in the application. ## Packages | Package | Contents | | --- | --- | | `AdCodicem.ValueObjects` | The existing generator gains the `[EntityId]` emission path. | | `AdCodicem.ValueObjects.Identifiers` | `[EntityId]`, the Crockford codec, the check character, the layout, the ambient provider, the prefix registry, `AnyEntityId`. | | `AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore` | Fixed-width column, binary collation, index guidance. | | `AdCodicem.ValueObjects.Secrets` | Bearer secrets. Specified below, not yet implemented. | **`[EntityId]` lives in `.Identifiers`, not in `Abstractions`.** If the attribute shipped with the core package while its runtime did not, a consumer could annotate a type and receive a compile error inside generated code they cannot edit. Placing the attribute in the package that carries its runtime makes that state unreachable: without the package, the attribute does not exist. ## Diagnostics | Id | Severity | Meaning | | --- | --- | --- | | `VO0015` | Error | Malformed prefix: empty, wrong characters, or an over-long segment. | | `VO0016` | Error | Two types in the compilation declare the same prefix. | | `VO0017` | Error | An option that `[EntityId]` derives or forbids was set by hand. | | `VO0018` | Error | Both `[EntityId]` and `[ValueObject]` on one type. | Cross-assembly prefix collisions are beyond a generator's reach and surface at start-up, when the second registration for a prefix is refused. ## Bearer secrets — specified, not implemented `sk_live_…` style secrets are a sibling of entity identifiers, not a mode of them, because they conflict with `IValueObject` on four points: - the interface requires `ISpanFormattable`, and a secret whose `ToString()` is masked cannot round-trip, so the implementation would be a lie; - the generated JSON converter writes `Value`, which would leak every secret that reaches a response body; - `IComparable` reopens a non-constant-time comparison, exactly what the secret mode exists to close; - `ValueObjectContract` asserts text and JSON round-tripping, and would have to be holed for every other type to accommodate secrets. They will therefore get their own `ISecretValueObject`, which implements none of those, and their own contract kit. Storage is `SHA-256` of the token with a unique index, and lookup is by hash. A slow KDF is **not** appropriate here: Argon2 and bcrypt exist to make low-entropy passwords expensive to guess, and a 128-bit random token is already beyond guessing — a slow hash would only add latency to every authenticated request. A displayable fragment (the last four characters) is stored alongside for the UI. Issuance, rotation, revocation and expiry stay with the consumer, symmetrically with the decision not to own the entity model for identifiers. Next: [Benchmarks](./benchmarks.md). # Benchmarks The measurements behind the [design decisions](./design-decisions.md). Absolute timings move a lot between runs on any one machine — the same unchanged code measured 248 ns in one run and 152 ns in another — so read the ratios, not the nanoseconds. Allocation figures are deterministic and comparable across runs. The full tables, including JSON round-tripping and the cost of each creation route, live in [`benchmarks/README.md`](https://github.com/AdCodicem/AdCodicem.ValueObjects/blob/main/benchmarks/README.md) in the repository, alongside the exact hardware and BenchmarkDotNet version each run used. Run them yourself with: ```bash cd benchmarks/AdCodicem.ValueObjects.Benchmarks dotnet run -c Release -- --filter '*WrapperCost*' # just the struct-vs-class comparison dotnet run -c Release -- --filter '*' # everything ``` They want a quiet machine — background load is the biggest source of the run-to-run variance above. ## Why a struct, even for a string `StructWrapper` and `ClassWrapper` are the same file twice, differing in one keyword. Anything between their rows below is the type kind and nothing else. | Operation, N = 100 000 | Time | Allocated | Alloc vs raw | | ----------------------- | ----------: | ----------: | -----------: | | Hold N raw strings | 0.810 ms | 800 KB | 1.00 | | Hold N struct wrappers | 0.805 ms | **800 KB** | **1.00** | | Hold N class wrappers | 1.846 ms | **3200 KB** | **4.00** | | Read N struct wrappers | 0.165 ms | 0 | — | | Read N class wrappers | 0.219 ms | 0 | — | | Box N struct wrappers | 0.638 ms | 2400 KB | — | | Box N class wrappers | 0.231 ms | 0 | — | Holding a hundred thousand struct wrappers costs **exactly what holding the bare strings costs**, to the byte: the wrapper is free. The class equivalent costs four times the memory and 2.28x the time — 24 bytes per instance for the object header, method table pointer and field, which is what making a wrapper a reference type costs. The last two rows are where a struct gives it back. Crossing a non-generic boundary boxes it: 2.76x slower than the class doing the same, and it allocates precisely the 24 bytes per instance the class had already paid up front. A struct value object is therefore only worth it if the hot paths stay generic — which the next table is the check for. ## Value objects in collections | Operation, 10 000 keys | Time | Allocated | | ------------------------ | ------------: | --------: | | Lookup by raw string | 242.2 µs | **0** | | Lookup by value object | 393.3 µs | **0** | | Sort raw strings | 1879.9 µs | 80 KB | | Sort value objects | **1815.6 µs** | 80 KB | **Every lookup allocates nothing**, which is the result that matters: the generated equality and hashing mean nothing boxes and nothing falls back to reflection-based equality. That is what makes the struct choice above safe. Sorting value objects even beats sorting the underlying strings, because `Array.Sort` devirtualizes `IComparable` on a struct where the string overload goes through a comparer. ## What buying validation at the boundary costs Deserializing a payload typed with value objects allocates exactly what deserializing the same payload of primitives allocates, byte for byte — the JSON reader copies text into a stack buffer and normalizes from it directly. It costs about 1.7x the primitive version in *time*, and that is the validation, not the wrapper: every field is normalized and checked on the way in. Buying guaranteed-valid values at the boundary for a few hundred nanoseconds is the trade the whole library exists to make. # Compared with other libraries AdCodicem.ValueObjects is not the first answer to primitive obsession in .NET. Three established libraries generate value objects too, and each of them is the better choice for some projects. This page is meant to help you tell which one yours is. The comparison was made in September 2026 against **Vogen 8.0.7**, **StronglyTypedId 1.0.0-beta08** — the version most of its users install, although nuget.org still lists 0.2.1 as the latest stable — and **Thinktecture.Runtime.Extensions 10.5.0**, from their source, their documentation and, where the documentation was unclear, a compiled test project. Libraries move: if something here is out of date, [open an issue](https://github.com/AdCodicem/AdCodicem.ValueObjects/issues) and it will be corrected. ## At a glance | | AdCodicem.ValueObjects | Vogen | StronglyTypedId | Thinktecture | | --- | --- | --- | --- | --- | | Consumer frameworks | .NET 10 | `netstandard2.0` and later | older frameworks too, including .NET Framework, with a .NET 7+ SDK | .NET 8 and later | | Type kinds | `readonly struct` | class, struct, record | struct | class, struct | | Underlying types | 22 built-in types | any type but a collection | `Guid`, `int`, `long`, `string`; others through templates | any type | | Length, pattern and range declared on the attribute | yes | no | no | no | | Validation and normalization hooks | interfaces, checked by the compiler | methods found by name | none | a partial method with `ref` parameters | | Rejection carries a stable error code | yes | no, a message | — | no, a message (custom error types possible) | | `default` and `new T()` rejected at build time | yes | yes | no | yes, for structs | | String comparison | ordinal, or declared per type | ordinal, configurable | ordinal | case-insensitive by default, configurable | | System.Text.Json | yes | yes | yes | yes | | Source-generated `JsonSerializerContext` | declared on the context, at compile time | a factory passed in the options at run time | converters passed in the options | not documented | | EF Core mapping | every value object of an assembly in one call | per property, or a marker class listing the types | per type | every value object in one call | | Column size from the type's rules | yes | no | no | no, a max-length strategy can be configured | | EF Core reads validate | on request (`strict: true`) | by default | — | no, reads use the constructor | | ASP.NET Core model binding | yes | through the `TypeConverter` | through the `TypeConverter` | yes | | Problem details carry the violated rule's code | yes | no | no | no | | OpenAPI | built-in stack, with lengths, pattern, bounds, `enum` | type and format; Swashbuckle or built-in stack | none | Swashbuckle, type of the key | | FluentValidation | yes | third-party package | no | no | | Dapper | yes | yes | through a template | no | | Other serializers and stores | Newtonsoft.Json | Newtonsoft.Json, LinqToDB, ServiceStack.Text, Orleans, MessagePack, BSON, XML | Newtonsoft.Json | Newtonsoft.Json, MessagePack | | Contract test kit for your own types | yes | no | no | no | | Prefixed public identifiers (`acc_…`) | yes | no | no | no | | Beyond single values | no | no | no | complex value objects, smart enums, discriminated unions | | Licence | MIT | Apache-2.0 | MIT | BSD-3-Clause | ## Where this library differs **A rule is declared once and reaches every boundary.** `MaxLength = 34` validates, sizes the EF Core column and becomes the OpenAPI `maxLength`; `Pattern` and `Minimum` do the same for the schema; known values become the `enum`. In the other three, a length or a pattern is code inside a validation method, so the column and the schema have to be told separately — which is exactly the drift that primitive obsession produces. **A rejection is data a client can act on.** Validation returns a `ValidationResult` struct holding a stable code and a message, and allocates nothing when the value is valid. The code travels to problem details responses and FluentValidation failures, so an API client branches on `value_object.too_long` rather than on English. **Rules are found by interface, not by name.** `IValueObjectValidator` and `IValueObjectNormalizer` let the compiler check each signature, and `VO0011` reports the one mistake left: a rule written without its interface, which would otherwise never run. **Source-generated JSON is declared, not remembered.** Naming `ValueObjectJsonConverterFactory` in `[JsonSourceGenerationOptions]` works at compile time, on the context itself. With Vogen and StronglyTypedId the converters have to be added to the options at run time instead, and forgetting them can fail quietly: in a test against Vogen 8.0.7, the value object was written as `{}` and read back uninitialized. **The integrations go further than serialization.** Problem details, FluentValidation rules that defer to the type, an OpenAPI schema carrying the constraints, a contract kit that tests your own types, and Stripe-style public identifiers are part of the library rather than left to you. ## Where the others are stronger **They run in more places.** This library requires .NET 10. Vogen targets `netstandard2.0`, StronglyTypedId runs on older frameworks including .NET Framework, and Thinktecture supports .NET 8. For an application that cannot move to .NET 10, the choice is made. **They accept more shapes.** Vogen wraps any type but a collection, as a class, a struct or a record; Thinktecture accepts any key, generic ones included. This library is deliberately limited to 22 underlying types and to `readonly struct`: [Design decisions](../design-decisions.md) explains why, but if you need a class or a `Uri`, it is not for you. **They cover more stores and serializers.** Vogen generates support for LinqToDB, ServiceStack.Text, Orleans, MessagePack, MongoDB's BSON and XML; Thinktecture for MessagePack. Both work with Swashbuckle, which this library does not support. **Thinktecture goes beyond single values.** Complex value objects with several members, smart enums and discriminated unions are out of scope here. **Vogen validates what it reads from the database by default.** This library skips validation on the EF Core read path unless asked, a performance choice that assumes the application owns its tables. For a database several systems write to, Vogen's default is the safer one; here, `strict: true` gives the same behaviour. **They are proven.** Vogen and StronglyTypedId have millions of downloads and years of production use behind them. This library was first published in September 2026 and is still at 0.x. ## Choosing - **On .NET 10, with an API and a database,** where a rule should be written once and reach the schema and the column: this library is built for that. - **On an older framework, or with a store this library does not cover:** Vogen. - **Only strongly-typed identifiers, and nothing to validate:** StronglyTypedId does that with the least ceremony. - **Smart enums, discriminated unions or multi-member value objects** alongside single values: Thinktecture. Moving from one of them? [Migrate from another library](../how-to/migrating.md) maps each one's surface onto this one. # How the library is tested To test your own value objects, see [Test your value objects](./how-to/test-value-objects.md). This page is about the library's own suites. Three suites, each with a distinct job: - **UnitTests** — behaviour of generated code, using sample value objects. Generated sources are emitted to disk during the build, so they can be read when diagnosing a failure instead of decompiled from memory. - **GeneratorTests** — the generator itself: emission, every diagnostic, hook detection, the analyzers, and incremental caching. It drives Roslyn directly rather than through a testing harness that binds to an older xUnit, and it compiles snippets **without** implicit usings, which is what catches an unqualified name that slipped into emitted code. The incrementality tests assert on the Roslyn incremental step run reason — the only way to notice a caching regression, since losing incrementality breaks nothing visible while making every IDE keystroke re-run the pipeline. - **IntegrationTests** — real PostgreSQL and SQL Server, asserting against `information_schema` that value objects reach the column types they claim, plus the API surface end to end. These need a Docker daemon: Testcontainers starts both engines for the run. `AdCodicem.ValueObjects.Testing` ships the contract kit (`ValueObjectContract`) described in [Test your value objects](./how-to/test-value-objects.md); the unit tests use it on every sample value object in the repository, so the framework's own test suite is a live example of how a consumer would use it. ## Two ways to reach a value object — and why bugs hide in one of them - **Typed path.** The static abstract members of `IValueObject` (`Create`, `TryCreate`, `TryParse`, `Normalize`, `Validate`). This is what domain code and the generic integrations use — the ASP.NET binder, the EF converter and the Dapper handler are all generic and closed over the concrete types at startup, so per-request work is fully typed and allocates nothing extra. - **Boxed path.** A descriptor resolved from a runtime registry, for callers that only know a `Type` at run time — dynamic parsing, the OpenAPI transformer, model-binder resolution. Unit tests naturally exercise the typed path, so a defect confined to the descriptor can be invisible to them unless the suite deliberately covers that surface too. That asymmetry is worth keeping in mind when adding a test for a new rule: prove it holds on both paths, not just the one that's convenient to call from a unit test. ## Stack xUnit v3, AwesomeAssertions, NSubstitute, Testcontainers. ## Commands ```bash dotnet build -c Release dotnet test -c Release # all three suites dotnet test tests/AdCodicem.ValueObjects.UnitTests # behaviour of generated code dotnet test tests/AdCodicem.ValueObjects.GeneratorTests # the generator itself dotnet test tests/AdCodicem.ValueObjects.IntegrationTests # needs Docker dotnet pack -c Release -o artifacts/packages # One test dotnet test tests/AdCodicem.ValueObjects.UnitTests --filter "FullyQualifiedName~The_name_of_the_test" ``` `TreatWarningsAsErrors` is on repository-wide, so a warning fails the build before it reaches any of the three suites. Next: [Benchmarks](./benchmarks.md), for the numbers behind the design decisions.