An answer to primitive obsession for .NET 10 and later: single-value DDD value objects, generated at compile time, with no reflection and no allocation on the paths that matter.
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:
using System.Text.RegularExpressions;
using AdCodicem.ValueObjects;
using AdCodicem.ValueObjects.Annotations;
namespace Banking;
[ValueObject<string>(
MinLength = 15,
MaxLength = 34,
SchemaFormat = "iban")]
public readonly partial struct Iban
: IValueObjectNormalizer<string>, IValueObjectPatternValidator, IValueObjectValidator<string>
{
// 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 holds. Compiled at build time, and published as the OpenAPI pattern.
[GeneratedRegex("^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)]
public static partial Regex Pattern { get; }
// 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.
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| 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, and fills in the JSON Schema System.Text.Json exports. |
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…. |
AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore |
Fixed-width, non-Unicode columns for those identifiers. |
AdCodicem.ValueObjects.Testing |
An xUnit contract kit for your own value objects. |
Every package targets net10.0, so it installs into a project on .NET 10 or any later version. The twelve are
released together under one version number: reference the same version of each. Their dependencies are minimums
with no upper bound, and the exact minimum of each is in the package's dependency list on nuget.org. A framework's
next major is supported by these same packages, never by a package per framework version
(ADR-0010).
| Package | Target | Built and tested against | On the next .NET¹ |
|---|---|---|---|
AdCodicem.ValueObjects |
net10.0 |
the .NET 10 SDK | the .NET 11 SDK, whose compiler runs the generator |
AdCodicem.ValueObjects.Abstractions |
net10.0 |
.NET 10 | .NET 11 |
AdCodicem.ValueObjects.Json |
net10.0 |
.NET 10, source generation included | .NET 11, source generation included |
AdCodicem.ValueObjects.EntityFrameworkCore |
net10.0 |
EF Core 10, on PostgreSQL and SQL Server | EF Core 11, on SQLite, PostgreSQL and SQL Server |
AdCodicem.ValueObjects.AspNetCore |
net10.0 |
ASP.NET Core 10 | ASP.NET Core 11 |
AdCodicem.ValueObjects.OpenApi |
net10.0 |
ASP.NET Core 10, with Microsoft.OpenApi 2 |
ASP.NET Core 11, with Microsoft.OpenApi 3 |
AdCodicem.ValueObjects.FluentValidation |
net10.0 |
FluentValidation 12 | FluentValidation 12 on .NET 11 |
AdCodicem.ValueObjects.Dapper |
net10.0 |
Dapper 2.1, on PostgreSQL and SQL Server | Dapper 2.1, on SQLite, PostgreSQL and SQL Server |
AdCodicem.ValueObjects.NewtonsoftJson |
net10.0 |
Newtonsoft.Json 13 | Newtonsoft.Json 13 on .NET 11 |
AdCodicem.ValueObjects.Identifiers |
net10.0 |
.NET 10 | .NET 11 |
AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore |
net10.0 |
EF Core 10, on PostgreSQL and SQL Server | EF Core 11, on SQLite, PostgreSQL and SQL Server |
AdCodicem.ValueObjects.Testing |
net10.0 |
xUnit v3 4 | xUnit v3 4 on .NET 11 |
¹ On the .NET 11 release candidate, by a CI job that installs the packages each commit builds into a net11.0
application. It informs and blocks nothing until .NET 11 ships.
The packages follow semantic versioning from 1.0.0 on: from then, only a major version breaks the public API or
removes a member. Before 1.0.0, the version is 0.<minor>.<patch>, and a minor version may break part of the public
API, or deprecate or remove part of it, without waiting for a major: read the
changelog before taking a new minor. A
patch never breaks anything, before 1.0.0 or after. A member deprecated rather than removed outright is reported by
the compiler wherever it is used, with a diagnostic naming its replacement. A member that is read by nothing any more
stays a while as a compile error that says what replaces it (VO0021, VO0028, VO0034, VO0035), and any minor
version may then remove it.
Between stable releases, a preview of every package is published to nuget.org when something a package ships has
changed, checked every week. It carries the number of the release it leads to, 0.3.0-preview.172 for instance,
and every package is published at that version:
dotnet add package AdCodicem.ValueObjects --prerelease
Previews receive no fixes of their own: a fix reaches the next preview and the next release. Every package, and every
assembly inside it, carries a signed build provenance attestation; SECURITY.md says how to check one.
A readonly partial struct, not a record struct. A record's with expression and field-wise equality
would both bypass validation and the configured comparison. The generator owns equality, ordering and hashing so
that Comparison = StringComparison.OrdinalIgnoreCase actually means something.
A struct, even when the underlying type is a string. Holding 100 000 struct wrappers allocates exactly
what holding 100 000 bare strings allocates, to the byte; the class equivalent costs four times the memory and
2.3x the time, because a reference type adds 24 bytes of header, method table pointer and field per instance.
The struct gives that back only when it crosses a non-generic boundary and boxes, so the generated equality,
hashing and comparison exist to keep the hot paths generic — dictionary lookups and sorts on value objects
allocate nothing. See benchmarks/ for the numbers and for where the struct loses.
default(Iban) is a build error. A struct can always be brought into existence uninitialized, and that is
the one hole a struct value object cannot close by itself. The VO0010 analyzer closes it at compile time,
which is what makes the struct representation — zero allocation, no null — safe to choose. Opt out per type with
AllowDefault = true. Another source generator cannot see the generated members, and may write new Iban() in
its own output: VO0032 reports it in the code of Riok.Mapperly and of the configuration binding generator, and
of any generator a .globalconfig adds. What reaches a boundary the analyzer cannot see — an entity property never
set, a default array element — is not written as it stands: the JSON converters, the Dapper handler and the EF Core
converters refuse an uninitialized instance whose value its type rejects, and an optional EF Core column stores a
NULL instead.
Rejection is not an exception. Validate returns a readonly struct that allocates nothing when the value
is valid. The integrations that take outside input go through TryCreate or TryParse and report a refusal in
their own terms: a JSON exception, a model state error, a FluentValidation failure, a Dapper DataException. Each
carries the code of the rule, which ValueObjectErrors.TryGetCode reads from any of those exceptions, and which
the problem details of an MVC controller carry for a JSON body as for a query value.
Create throws ValueObjectException, and is for the call sites that want it; a strict EF Core read goes through
it, and fails the query. Validation is fail-fast: the first violated rule wins.
Normalize, then validate, then assign. So a non-default instance is by construction both normalized and
valid. It happens on construction, on parsing, on deserialization and on model binding — but not when
materializing a row from the database, which is the hottest path in most applications and reads values this
same application wrote. ConfigureValueObjects(strict: true) turns that back on for a table another system
also writes to.
Rules are declared once. MaxLength = 34 validates the value, sizes the EF Core column, and becomes the
maxLength keyword of the OpenAPI schema. The [GeneratedRegex] behind IValueObjectPatternValidator
validates the value, and its text becomes the pattern keyword. The members marked [KnownValue] become a frozen
membership lookup and the enum keyword of the schema, with their names beside it for generated clients.
The same rules fill in the JSON Schema System.Text.Json exports, which AI tools, structured output and MCP servers
describe their parameters with, through ValueObjectJsonSchema.
Vogen, StronglyTypedId and Thinktecture.Runtime.Extensions generate value objects too, and each is the better choice for some projects: an older target framework, a class or an arbitrary underlying type, smart enums and unions. What sets this one apart is that a rule declared on the type also reaches the EF Core column and the OpenAPI schema, and that a rejection carries a stable error code all the way to an MVC controller's response. The comparison has the full table, including where the others are stronger, and the migration guide maps each library's surface onto this one.
dotnet add package AdCodicem.ValueObjects
Then wire up whichever boundaries you have:
builder.Services.AddControllers().AddValueObjects();
builder.Services.Configure<ApiBehaviorOptions>(o => o.AddValueObjectProblemDetails());
builder.Services.AddOpenApi(o => o.AddValueObjects());
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
=> builder.ConfigureValueObjects(typeof(Iban).Assembly);Minimal APIs need no package to bind: a generated value object implements IParsable<T>, which is exactly what
minimal API parameter binding looks for. A value it rejects is answered there with a bare 400, naming neither the
parameter nor the rule: the problem details carrying the rule's code are MVC's. Where the Request Delegate Generator
writes that binding, in a project that sets PublishAot or PublishTrimmed, a value object declared in the project
that maps the endpoints also lists its contract on its declaration,
public readonly partial struct Sku : IValueObject<Sku, string>;, because that generator does not see what this one
adds; VO0033 reports one that does not, and
the ASP.NET Core guide
explains it. A value object from another project needs nothing.
public sealed class IbanContract : ValueObjectContract<Iban, string>
{
protected override IEnumerable<string> AcceptedValues => ["FR7630006000011234567890189"];
protected override IEnumerable<string> RejectedValues => ["", "not-an-iban"];
}That derives over a dozen checks: normalization settles, equality and ordering agree, text and JSON round-trip, rejected values are rejected the same way by every entry point, the declared example and known values are values the type accepts, and the schema names each known value in its place.
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.
| Option | Effect |
|---|---|
MinLength, MaxLength |
Validation, EF column size, OpenAPI schema. |
Pattern |
Removed: a compile error (VO0021), read by nothing. Implement IValueObjectPatternValidator instead. Any minor version may remove the property before 1.0.0. |
Minimum, Maximum |
Removed: a compile error (VO0028), read by nothing. Implement IValueObjectMinimum<T> and IValueObjectMaximum<T> instead. Any minor version may remove the properties before 1.0.0. |
Comparison |
Equality, ordering and hashing for string value objects. Ordinal by default. |
ValueSet = Closed + [KnownValue] members |
Reference-data codes with a frozen lookup and a schema enum, whose values a generated client names after the known values (x-enum-varnames, x-enumNames, x-ms-enum), so renaming one renames its member there. Members of a closed set over a reference type are boxed once and shared, so the boxed paths allocate nothing. |
Arithmetic |
Operators and generic math for numeric value objects. Every result is re-validated. |
ImplicitConversionToValue, ExplicitConversionFromValue |
Conversions, opt-in per type. |
AllowEmpty, AllowDefault |
Loosen the two defaults that exist to catch mistakes. |
SchemaFormat, Description |
OpenAPI documentation. |
Example |
Removed: a compile error (VO0035), read by nothing. Implement IValueObjectExample<TSelf> instead. Any minor version may remove the property before 1.0.0. |
A known value is a member of the type, marked [KnownValue] and initialized through the generated Known, so the
compiler checks its name and the type of its value:
[ValueObject<string>(ValueSet = ValueSetKind.Closed, MinLength = 2, MaxLength = 2)]
public readonly partial struct CountryCode
{
/// <summary>France.</summary>
[KnownValue]
public static readonly CountryCode France = Known("FR");
[KnownValue(Description = "Belgium")]
public static readonly CountryCode Belgium = Known("BE");
}It is a static readonly field or a static get-only auto-property of the type, of any accessibility. Known
applies every rule of the type but membership, which a known value satisfies by declaration, and is called nowhere
else (VO0037). The generator lists the known values in KnownValues, builds the lookup of a closed set from them,
and publishes them in the schema, each with the Description of its attribute or the <summary> of its member. A
member it cannot read as a known value is VO0036. A known value or an example the type's own rules refuse is
VO0031 when it is a constant the generator can evaluate the rule on; the contract kit checks the rest.
[KnownValue("France", "FR")] on the type, the form that took the value as text, is VO0034, and a code fix
rewrites it into the member.
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<TValue> |
static TValue NormalizeValue(TValue value) |
IValueObjectSpanNormalizer |
static string NormalizeValue(ReadOnlySpan<char> value) — string value objects only |
IValueObjectPatternValidator |
static Regex Pattern { get; }, written as a [GeneratedRegex] partial property — string value objects only |
IValueObjectValidator<TValue> |
static ValidationResult ValidateValue(in TValue value) |
IValueObjectMinimum<TValue>, IValueObjectMaximum<TValue> |
static TValue Minimum { get; }, static TValue Maximum { get; } — numbers, char, dates, times and durations |
IValueObjectFormatter<TValue> |
static bool TryFormatValue(in TValue value, Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider) |
IValueObjectStringFormatter<TValue> |
static string FormatValue(in TValue value, ReadOnlySpan<char> format, IFormatProvider? provider) |
IValueObjectExample<TSelf> |
static TSelf Example { get; } — the OpenAPI example, an instance of the type itself |
NormalizeValue must be idempotent and must not reject: an unnormalizable value is rejected by
ValidateValue. A formatting hook, when present, takes over formatting entirely, including the default format:
ToString(), ToString(format, provider), TryFormat and interpolation all write what it writes. When a type
declares both, FormatValue answers everywhere and TryFormatValue is never called; TryFormat then copies the
string FormatValue returns. Formatting stops at text for people: JSON, dictionary keys included, and the
database carry the underlying value.
Adding IValueObjectSpanNormalizer alongside IValueObjectNormalizer<string> 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:
public readonly partial struct Iban : IValueObjectNormalizer<string>, IValueObjectSpanNormalizer
{
public static string NormalizeValue(string value) => NormalizeValue(value.AsSpan());
public static string NormalizeValue(ReadOnlySpan<char> value)
{
Span<char> buffer = value.Length <= 64 ? stackalloc char[64] : new char[value.Length];
// ... write the normalized characters into buffer ...
return new string(buffer[..length]);
}
}IValueObjectPatternValidator takes a [GeneratedRegex] you write, as in the IBAN above, because the regex
source generator compiles only code a person wrote: one source generator never sees another's output, so this
one cannot write the attribute for you. The pattern runs after MinLength and MaxLength, before the known
values and ValidateValue, and rejects a value as value_object.invalid_format. Its text, read off the attribute
when the type compiles, is also the OpenAPI pattern. That text carries no RegexOptions, so VO0025 warns on
IgnoreCase, Multiline, Singleline and IgnorePatternWhitespace: write such a rule into the pattern itself.
VO0026 warns on a missing matchTimeoutMilliseconds. Regex lives in System.Text.RegularExpressions, which
is not among the implicit usings.
The hook replaces the Pattern option, which built its regular expression at run time, where native AOT
interprets it. Setting the option is now a compile error (VO0021). To migrate, move the expression from
Pattern = "X" into
[GeneratedRegex("X", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] public static partial Regex Pattern { get; }:
those are the options and the timeout the option used, so behaviour does not change.
IValueObjectMinimum<TValue> and IValueObjectMaximum<TValue> declare inclusive bounds as values of the
underlying type, so the compiler checks them and any expression of that type builds them:
[ValueObject<DateOnly>]
public readonly partial struct BirthDate : IValueObjectMinimum<DateOnly>, IValueObjectMaximum<DateOnly>
{
public static DateOnly Minimum => new(1900, 1, 1);
public static DateOnly Maximum => new(2100, 12, 31);
}They run after the pattern, before the known values and ValidateValue, and reject a value as
value_object.out_of_range. They are also the OpenAPI minimum and maximum, or the x-minimum and x-maximum
extensions for a type JSON writes as a string. A bound is a constant, written as an expression-bodied property: the
check reads it each time and the schema once, as the assembly loads, so a bound relative to the clock is a rule for
ValidateValue. Over a string, a Guid, a bool, an
[EntityId] or another type than the underlying one, the hooks are VO0030. They replace the Minimum and
Maximum options, which held the bounds as text and are now a compile error (VO0028).
IValueObjectExample<TSelf> declares the example the OpenAPI schema publishes, as an instance of the type, which
its rules have accepted: public static Percentage Example => Create(42);. Without it, a value object publishes no
example, and an [EntityId] one of the right shape. It replaces the Example option, which held the example as
text and is now a compile error (VO0035).
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.
AdCodicem.ValueObjects.Identifiers adds public identifiers in the shape everyone recognizes from Stripe.
[EntityId("acc")]
public readonly partial struct AccountId;
var id = AccountId.New(); // acc_1kcv3ahrz6dmv29gqy5cvThat is a value object like any other — same parsing, same JSON, same column, same contract kit — plus New(),
Prefix, Granularity and Length. The prefix is what makes cus_… fail to parse as an AccountId, so
swapping one identifier for another in a request parameter is refused at the boundary instead of reaching a
repository. It is stored in the database for the same reason: a raw-SQL join between two tables holding bare
bodies would succeed silently.
The body is 80 bits from a CSPRNG, in Crockford Base32, behind a coarse time bucket and followed by a check character:
- the time bucket gives the index a monotonic head, so inserts land at the right edge of the B-tree instead
of scattering across it. It leaks the creation time at the granularity you choose —
Hourby default,MinuteorDayon request — and nothing finer. It does not make an identifier guessable: the random part keeps its full 80 bits regardless; - the check character catches every single mistyped character and almost every adjacent transposition offline, before a query is ever sent, and covers the prefix too, so a body copied between two identifier types is rejected even by a parser that does not know which prefix to expect;
- the alphabet ascends in ASCII, so ordinal comparison — this library's default — sorts identifiers
chronologically. It is lower case, so an identifier is one unbroken token; upper case and the aliases
(
i,l→1,o→0) fold on the way in, which makes the stored value canonical and takes a case-insensitive column collation out of the correctness path. Crockford's optional hyphen is not accepted: one identifier, one spelling.
Length is fixed per type, so the column is char(n) and the OpenAPI pattern, minLength and maxLength
follow from the profile without being declared.
AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore turns that fixed width into the narrowest column that
holds it — char(n) rather than varchar(n), and non-Unicode, so SQL Server does not silently double it to
nchar for an alphabet of 32 ASCII symbols:
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
=> builder.ConfigureEntityIds(typeof(AccountId).Assembly);A binary collation (IdCollations.SqlServer, IdCollations.PostgreSql) is worth setting and is a performance
choice rather than a correctness one, precisely because normalization already made the stored value canonical.
What the package deliberately leaves to you is the physical layout: on SQL Server a primary key is clustered by
default, and IsClustered(false) confines index churn to the 30-byte index instead of the whole row.
AnyEntityId parses whichever registered prefix arrives, for webhooks, deep links and audit trails. It
implements neither IValueObject nor IEntityId, which is what keeps it out of the EF Core convention: a
polymorphic column cannot be mapped by accident.
New() reads an ambient TimeProvider and IdEntropySource. Tests substitute them without an injected
factory reaching every aggregate:
using (ValueObjectIds.Use(fakeClock, deterministicBytes))
{
var id = AccountId.New();
}The scope is bound to the execution flow, so suites running in parallel do not interfere.
Entity Identifiers carries the format, the arithmetic behind the widths, and the reasoning — including why there is one identity rather than an internal surrogate key alongside it.
VO0004, VO0006, VO0013, VO0014, VO0022 and VO0029 reported options written as text, which no longer
compile, and are retired. There is no VO0012.
| Id | Severity | Meaning |
|---|---|---|
VO0001 |
Error | The type is not partial. |
VO0002 |
Error | The type is not a readonly struct: a class, an interface, a record, a ref struct, or a struct without readonly. |
VO0003 |
Error | Unsupported underlying type. |
VO0005 |
Error | A closed value set declares no value. |
VO0007 |
Error | Arithmetic requested on a non-numeric type. |
VO0008 |
Warning | Length constraints on a non-string type. |
VO0009 |
Error | A containing type is not partial. |
VO0010 |
Error | An uninitialized value object. |
VO0011 |
Warning | A rule written without declaring its hook interface, so the generator will never call it. |
VO0015 |
Error | A malformed entity identifier prefix. |
VO0016 |
Error | Two types claiming the same prefix. |
VO0017 |
Error | A normalization hook on an entity identifier, which owns its own. |
VO0018 |
Error | Both [EntityId] and [ValueObject<T>] on one type. |
VO0019 |
Error | The generated code cannot reopen, reach or name the type: it, or a type around it, is file-local; it is private or protected, or nested in such a type, inside a generic type; it is a generic [EntityId], or one in a generic type; it has a type parameter it cannot use; or it is named after a member the generator writes on it. |
VO0020 |
Error | Comparison, ValueSet or Granularity holds a value its enum does not define. |
VO0021 |
Error | The Pattern option of [ValueObject<T>], reported by the compiler and read by nothing. Implement IValueObjectPatternValidator with [GeneratedRegex("X", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] public static partial Regex Pattern { get; } and remove Pattern = "X". Any minor version may remove the option before 1.0.0. |
VO0023 |
Error | IValueObjectPatternValidator on a value object whose underlying type is not string. |
VO0024 |
Error | IValueObjectPatternValidator on an [EntityId], which validates and publishes its own format. |
VO0025 |
Warning | The [GeneratedRegex] behind Pattern sets IgnoreCase, Multiline, Singleline or IgnorePatternWhitespace, which the OpenAPI pattern cannot carry. |
VO0026 |
Warning | The [GeneratedRegex] behind Pattern sets no matchTimeoutMilliseconds. |
VO0027 |
Error | [KnownValue] on a member of an [EntityId], which generates no known values. |
VO0028 |
Error | The Minimum or Maximum option of [ValueObject<T>], reported by the compiler and read by nothing. Implement IValueObjectMinimum<T> or IValueObjectMaximum<T> with a static property of the underlying type and remove the option. Any minor version may remove it before 1.0.0. |
VO0030 |
Error | IValueObjectMinimum<T> or IValueObjectMaximum<T> over a type that takes no bound, or over another type than the underlying one. |
VO0031 |
Error | The example or a known value declared on the type is one its own rules refuse, wherever the generator can evaluate them on a constant: a length, an empty string, a bound returned as a constant, a closed value set. The contract kit checks the rest at run time. |
VO0032 |
Error | A value object created uninitialized, by default or new T(), in code another source generator wrote: Riok.Mapperly, the configuration binding generator, or a tool adcodicem_value_objects.generated_code_tools names in a .globalconfig. |
VO0033 |
Warning | A value object whose own declaration lists no interface bringing IParsable<TSelf>, in a project where the Request Delegate Generator runs and that references ASP.NET Core's endpoint routing: that generator would bind it from the request body. A code fix lists the contract. |
VO0034 |
Error | [KnownValue("France", "FR")] on the type, the form that took the value as text, reported by the compiler and read by nothing. A code fix rewrites it into a member, [KnownValue] public static readonly CountryCode France = Known("FR");. Any minor version may remove the form before 1.0.0. |
VO0035 |
Error | The Example option of [ValueObject<T>] or [EntityId], reported by the compiler and read by nothing. Implement IValueObjectExample<TSelf>. Any minor version may remove it before 1.0.0. |
VO0036 |
Error | A member marked [KnownValue] that is not a static readonly field or a static get-only auto-property of the type, initialized by Known(...) with the value as its one argument. |
VO0037 |
Error | Known called anywhere but in the initializer of a member marked [KnownValue], which would skip the membership of a closed set. |
VO0038 |
Error | IValueObjectExample<T> over another type than the value object itself. |
Because the whole implementation is generated, a model that has never seen this library guesses the surface
wrong: a hand-written factory, a record struct, a JsonConverter nobody needs, a rule that never runs
because its interface was not declared. skills/value-objects/ states that surface as an agent skill — the
attribute options, the hook interfaces, the wiring of each integration, and every VO00xx diagnostic with its
fix. In Claude Code:
/plugin marketplace add AdCodicem/AdCodicem.ValueObjects
/plugin install adcodicem-valueobjects@adcodicem
Any other agent can read the same files straight from the repository — they are plain Markdown. Every C# snippet in them is compiled by the generator test suite, so the skill cannot drift away from the generator without failing the build.
src/ the shipped packages
tests/ unit tests, generator tests, and integration tests on real database engines
Compat/ the packed packages in a .NET 11 application, outside the solution
samples/ a showcase API exercising the whole chain end to end
benchmarks/ the measurements behind the design decisions above
skills/ the agent skill, and the plugin manifest that distributes it
Integration tests start PostgreSQL and SQL Server through Testcontainers, so they need a Docker daemon.
dotnet build
dotnet test --project tests/AdCodicem.ValueObjects.UnitTests # no Docker needed
dotnet test --project tests/AdCodicem.ValueObjects.GeneratorTests # no Docker needed
dotnet test # everything, Docker required
dotnet pack -c Release
Benchmarks are a separate run, and want a quiet machine:
cd benchmarks/AdCodicem.ValueObjects.Benchmarks
dotnet run -c Release -- --filter * # everything
dotnet run -c Release -- --filter *WrapperCost* # just the struct against class comparison
pip install pre-commit
pre-commit install
installs a pre-commit and a commit-msg hook that also run in CI (.github/workflows/lint.yml):
committed files must stay usable on a case-insensitive, no-symlink Windows checkout, and commit
messages must follow Conventional Commits.
MIT.
