AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore 0.2.1

Prefix Reserved
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore --version 0.2.1
                    
NuGet\Install-Package AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore -Version 0.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore" Version="0.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore" Version="0.2.1" />
                    
Directory.Packages.props
<PackageReference Include="AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore --version 0.2.1
                    
#r "nuget: AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore, 0.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore@0.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore&version=0.2.1
                    
Install as a Cake Addin
#tool nuget:?package=AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore&version=0.2.1
                    
Install as a Cake Tool

AdCodicem.ValueObjects

ci NuGet codecov OpenSSF Scorecard License: MIT

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.

Documentation

Primitive obsession

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 AdCodicem.ValueObjects;
using AdCodicem.ValueObjects.Annotations;

namespace Banking;

[ValueObject<string>(
    MinLength = 15,
    MaxLength = 34,
    Pattern = "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$",
    SchemaFormat = "iban")]
public readonly partial struct Iban : IValueObjectNormalizer<string>, 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 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

Packages

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….
AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore Fixed-width, non-Unicode columns for those identifiers.
AdCodicem.ValueObjects.Testing An xUnit contract kit for your own value objects.

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/ 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.

Compared with other libraries

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 the API 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.

Getting started

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 nothing: a generated value object implements IParsable<T>, which is exactly what minimal API parameter binding looks for.

Testing your own value objects

public sealed class IbanContract : ValueObjectContract<Iban, string>
{
    protected override IEnumerable<string> AcceptedValues => ["FR7630006000011234567890189"];
    protected override IEnumerable<string> RejectedValues => ["", "not-an-iban"];
}

That derives 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.

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<T>]

Option Effect
Pattern, MinLength, MaxLength Validation, EF column size, OpenAPI schema.
Minimum, Maximum Written in invariant culture, parsed at compile time.
Comparison Equality, ordering and hashing for string value objects. Ordinal by default.
ValueSet = Closed + [KnownValue] Reference-data codes with a frozen lookup and a schema enum. 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.

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<TValue> static TValue NormalizeValue(TValue value)
IValueObjectSpanNormalizer static string NormalizeValue(ReadOnlySpan<char> value) — string value objects only
IValueObjectValidator<TValue> static ValidationResult ValidateValue(in TValue value)
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)

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<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]);
    }
}

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.

Entity identifiers

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_1kcv3ahrz6dmv29gqy5cv

That 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 — Hour by default, Minute or Day on 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.

Diagnostics

Id Severity Meaning
VO0001 Error The type is not partial.
VO0002 Error The type is not a readonly struct, or is a record.
VO0003 Error Unsupported underlying type.
VO0004 Error A bound could not be parsed.
VO0005 Error A closed value set declares no value.
VO0006 Error A known value has an unusable name.
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.
VO0013 Error A known value could not be converted.
VO0014 Error An invalid regular expression.
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.

Using it with an AI coding agent

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.

Repository layout

src/          the shipped packages
tests/        unit tests, generator tests, and integration tests on real database engines
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.

Building

dotnet build
dotnet test tests/AdCodicem.ValueObjects.UnitTests        # no Docker needed
dotnet test 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

Contributing

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.

Licence

MIT.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0-preview.205 24 10/4/2026
0.3.0-preview.197 28 10/3/2026
0.2.2-preview.0.172 32 10/3/2026
0.2.2-preview.0.144 28 10/2/2026
0.2.2-preview.0.143 32 10/2/2026
0.2.2-preview.0.138 31 10/2/2026
0.2.2-preview.0.137 34 10/2/2026
0.2.2-preview.0.136 35 10/2/2026
0.2.2-preview.0.135 33 10/1/2026
0.2.2-preview.0.100 35 10/1/2026
0.2.2-preview.0.2 31 9/30/2026
0.2.2-preview.0.1 37 9/30/2026
0.2.1 63 9/30/2026
0.2.1-preview.0.12 33 9/30/2026
0.2.1-preview.0.11 35 9/30/2026
0.2.1-preview.0.4 51 9/24/2026
0.2.1-preview.0.1 49 9/24/2026
0.2.0 83 9/24/2026
0.1.1-preview.0.31 53 9/24/2026
0.1.1-preview.0.30 58 9/23/2026
Loading failed