ZeroAlloc.ValueObjects.EfCore
2.2.1
dotnet add package ZeroAlloc.ValueObjects.EfCore --version 2.2.1
NuGet\Install-Package ZeroAlloc.ValueObjects.EfCore -Version 2.2.1
<PackageReference Include="ZeroAlloc.ValueObjects.EfCore" Version="2.2.1" />
<PackageVersion Include="ZeroAlloc.ValueObjects.EfCore" Version="2.2.1" />
<PackageReference Include="ZeroAlloc.ValueObjects.EfCore" />
paket add ZeroAlloc.ValueObjects.EfCore --version 2.2.1
#r "nuget: ZeroAlloc.ValueObjects.EfCore, 2.2.1"
#:package ZeroAlloc.ValueObjects.EfCore@2.2.1
#addin nuget:?package=ZeroAlloc.ValueObjects.EfCore&version=2.2.1
#tool nuget:?package=ZeroAlloc.ValueObjects.EfCore&version=2.2.1
ZeroAlloc.ValueObjects
Zero-allocation source-generated ValueObject equality for your existing domain types.
Same performance as record — without forcing the record keyword on your domain model. Add [ValueObject] to any partial class or partial struct and the generator emits Equals, GetHashCode, == and != with no heap allocations, plus a ToString override. ToString returns a new string, so it allocates like any other ToString.
Install
dotnet add package ZeroAlloc.ValueObjects
Quick start
using ZeroAlloc.ValueObjects;
// Annotate any existing partial class — no keyword changes, no base class
[ValueObject]
public partial class Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency);
}
// Use standard equality — zero allocations
var a = new Money(10m, "USD");
var b = new Money(10m, "USD");
bool equal = a == b; // true
bool same = a.Equals(b); // true — IEquatable<Money> fast path
int hash = a.GetHashCode(); // same as b.GetHashCode() — safe as dict key
string s = a.ToString(); // "Money { Amount = 10, Currency = USD }"
Performance
Multi-field value objects (Money(decimal Amount, string Currency)): ZA matches record / record struct exactly. CFE allocates ~90 B per call.
| Method | Mean | Allocated |
|---|---|---|
| CFE_Equals | 45.2 ns | 96 B |
| Record_Equals | 3.1 ns | 0 B |
| RecordStruct_Equals | 2.8 ns | 0 B |
| ZeroAlloc_Equals | 3.1 ns | 0 B |
| ZeroAllocStruct_Equals | 2.8 ns | 0 B |
| CFE_GetHashCode | 38.7 ns | 88 B |
| Record_GetHashCode | 2.4 ns | 0 B |
| RecordStruct_GetHashCode | 2.2 ns | 0 B |
| ZeroAlloc_GetHashCode | 2.4 ns | 0 B |
| ZeroAllocStruct_GetHashCode | 2.2 ns | 0 B |
The 0 B for Equals, GetHashCode, == and != is asserted on a class and a struct by
EqualityAllocationTests.
Single-int wrapped IDs vs Vogen (the source-gen alternative):
| Operation | Vogen | ZA.ValueObjects | Winner |
|---|---|---|---|
From(value) |
4.66 ns | 0.39 ns | ZA 12× faster |
Equals (equal) |
1.15 ns | 0.09 ns | ZA 13× faster |
Equals (not equal) |
0.31 ns | 0.02 ns | ZA 15× faster |
GetHashCode |
0.03 ns | 0.42 ns | parity (both in ZeroMeasurement zone) |
ToString |
6.40 ns | 3.52 ns | ZA 1.8× faster |
ZA wins or ties every row, with 0 B allocated on all of them. The ToString row is 0 B only because the benchmark formats 42, and .NET caches the strings of small integers; formatting a larger value returns a new string. The previous regressions on GetHashCode (~30× slower) and ToString (~72 B allocation) were closed in v1.7 by aligning the single-property emit to Value.ToString(InvariantCulture) / Value.GetHashCode() directly. ZA also supports multi-field types — [ValueObject] on a partial class or partial struct with any number of fields — which Vogen does not.
Full methodology and analysis: docs/performance.md
Features
- Zero-allocation
Equals,GetHashCode,==and!=— no iterator state machine, no boxing - Works on existing
partial classandpartial struct— no refactoring required - Can inherit from non-record base classes
- Fine-grained member control with
[EqualityMember](opt-in) and[IgnoreEqualityMember](opt-out) - No extra generated members — no
with, noDeconstruct, noEqualityContract - Null-safe comparison for nullable reference type properties
HashCode.Combinefor ≤8 properties, incrementalHashCode.Addfor 9+
Why not just use record?
record |
ZeroAlloc.ValueObjects |
|
|---|---|---|
| Zero-allocation equality | ✓ | ✓ |
Works on existing class/struct |
✗ — forces record keyword |
✓ |
| Can inherit from non-record base | ✗ | ✓ |
| Fine-grained member control | ✗ | [EqualityMember] / [IgnoreEqualityMember] |
| No extra generated members | ✗ — adds EqualityContract, with, deconstruct |
✓ |
| Struct support | record struct |
partial struct |
Typed identifiers
[TypedId] is the companion attribute for strongly-typed IDs — OrderId, UserId, MessageId. It solves the same problem as the ValueObject attribute, but tailored for single-value identifiers with built-in generation strategies.
Basic usage
using ZeroAlloc.ValueObjects;
[TypedId(Strategy = IdStrategy.Ulid)]
public readonly partial record struct OrderId;
// Usage
OrderId id = OrderId.New(); // monotonic ULID
string s = id.ToString(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV" — 26-char base32
OrderId parsed = OrderId.Parse(s); // round-trip
Strategies
| Strategy | Backing | Format | Use case |
|---|---|---|---|
Ulid (default) |
Guid |
26-char Crockford base32 | General-purpose, sortable, URL-safe |
Uuid7 |
Guid |
36-char hyphenated UUID | Time-ordered with standard UUID interop |
Snowflake |
long |
Decimal string | Distributed systems needing 64-bit IDs |
Sequential |
long |
Decimal string | Test stability only — not for production |
Assembly-level default
[assembly: TypedIdDefault(Strategy = IdStrategy.Ulid)]
[TypedId] // resolves to Ulid from the assembly default
public readonly partial record struct ProductId;
[TypedId(Strategy = IdStrategy.Snowflake)] // per-struct override
public readonly partial record struct MessageId;
Snowflake worker ID configuration
Snowflake IDs encode a 10-bit worker ID so multiple processes can mint IDs concurrently without collision. Configure at startup:
builder.Services.AddSnowflakeWorkerId(workerId: 5);
builder.Services.AddSnowflakeWorkerId(envVar: "POD_ORDINAL", fallback: 0);
builder.Services.AddSnowflakeWorkerId(() => ComputeWorkerIdFromHostname());
These publish the worker ID immediately — no host required.
To derive the ID from a registered service, install ZeroAlloc.ValueObjects.Hosting, which adds an overload backed by a hosted service:
builder.Services.AddSnowflakeWorkerId(sp => sp.GetRequiredService<IMachineIdProvider>().Id);
It lives in a separate package so the core one carries no Microsoft.Extensions.Hosting.Abstractions dependency. The namespace is the same, so only the package reference changes.
If no provider is registered, the generated New() of a Snowflake-strategy ID, such as MessageId.New(), falls back to ZA_SNOWFLAKE_WORKER_ID env var, then throws TypedIdException.
EF Core
Install ZeroAlloc.ValueObjects.EfCore and call the registration the generator emits for your assembly, here MyApp:
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.AddMyAppTypedIdConventions();
}
All [TypedId] structs in the assembly are mapped: Guid-backed → uniqueidentifier/uuid, long-backed → bigint. The generated Add{Assembly}TypedIdConventions() calls Properties<TId>().HaveConversion<TypedIdValueConverter<TId, TBacking>>() for each struct by name, so it needs no reflection or assembly scan and is trim-safe and NativeAOT-safe. Each struct also gets OrderId.AddTypedIdConvention(builder) to register one ID. The reflection-based builder.AddTypedIdConventions() remains for apps that do not trim. Per-property HasConversion still overrides. See EF Core integration.
Minimal API binding
No setup needed — the generator emits IParsable<T> + ISpanParsable<T>, so app.MapGet("/orders/{id}", (OrderId id) => …) just works.
JSON
Each TypedId carries [JsonConverter] pointing at a nested converter that reads/writes a string. Fully AOT-safe. No JsonSerializerContext wiring required for basic use; if you're source-generating JsonSerializerContext, include the TypedId types there too.
Compile-time diagnostics
| ID | Severity | Meaning |
|---|---|---|
ZATI001 |
Error | Incompatible strategy/backing (e.g. Snowflake + Guid) |
ZATI002 |
Error | Type is not readonly partial record struct |
ZATI003 |
Error | Struct body declares fields — generator owns Value |
ZATI005 |
Warning | Struct declared partial across multiple files |
ZATI006 |
Warning | Nested struct inside a containing type that is not partial |
ZATI007 |
Error | Struct is generic, or nested in a generic type |
ZATI008 |
Error | Struct is file-local |
ZATI009 |
Error | Struct name differs only in case from another TypedId |
ZATI010 |
Error | Invalid ZeroAllocGeneratedAccessibility MSBuild property value |
[ValueObject] reports ZAVO001–ZAVO003 for value objects it cannot generate; see docs/diagnostics.md.
Production checklist
- Sequential is not for production. The counter resets on process restart. Use it only in tests where deterministic IDs matter.
- Snowflake worker IDs must be unique across all producing processes.
AddSnowflakeWorkerIdcannot detect duplicates. Coordinate via orchestrator ordinals (Kubernetes pod index, Nomad alloc index) or a central registry. Duplicate worker IDs silently produce colliding IDs. - Clock skew matters. Snowflake generation handles small rollbacks by pinning to the last observed millisecond, but severe skew (>5s) throws
TypedIdException. Run NTP-synced or accept the brief unavailability. - Process restart loses Sequential state but not Snowflake or ULID/UUID7 ordering. ULID/UUID7 are globally safe to restart; Snowflake is safe if worker ID is stable across restarts.
Documentation
| Page | Description |
|---|---|
| Why this library? | The problem with CFE, why not just use record |
| Installation | NuGet install, .NET version requirements |
| Getting Started | Step-by-step quickstart with core concepts |
| Attribute Reference | [ValueObject], [EqualityMember], [IgnoreEqualityMember] |
| Member Selection | How properties are chosen for equality |
| Generated Output | Exact code the generator emits |
| Struct vs. Class | When to use each, ForceClass |
| Nullable Properties | Null-safe comparison generation |
| Usage Patterns | Dictionary keys, HashSets, LINQ, EF Core, pattern matching |
| Migration Guide | From CFE ValueObject, from manual equality |
| Performance | Benchmark results and how to run them |
| Design Decisions | Trade-offs, intentional omissions |
| Troubleshooting | Common errors and fixes |
| Testing | Writing unit tests for value object equality |
| Examples | |
| E-Commerce | ProductId, Money, ShippingAddress, Discount |
| Finance | Iban, CurrencyPair, AccountNumber |
| HR / Identity | EmailAddress, EmployeeId, FullName |
| Geospatial | Coordinates, GeoRegion |
| Scheduling | DateRange, TimeSlot |
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. 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. |
-
net8.0
- Microsoft.EntityFrameworkCore (>= 9.0.20)
- ZeroAlloc.ValueObjects (>= 2.2.1)
-
net9.0
- Microsoft.EntityFrameworkCore (>= 9.0.20)
- ZeroAlloc.ValueObjects (>= 2.2.1)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on ZeroAlloc.ValueObjects.EfCore:
| Package | Downloads |
|---|---|
|
ZeroAlloc.Scheduling.EfCore
EF Core job store adapter for ZeroAlloc.Scheduling. |
|
|
ZeroAlloc.Outbox.EfCore
EF Core store adapter for ZeroAlloc.Outbox. |
|
|
ZeroAlloc.Saga.EfCore
Entity Framework Core durable persistence backend for ZeroAlloc.Saga. Single shared SagaInstance table, row-version OCC, retry-on-conflict. Native AOT compatible. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2.2.1 | 0 | 10/15/2026 |
| 2.2.0 | 187 | 10/12/2026 |
| 2.1.1 | 990 | 10/11/2026 |
| 2.1.0 | 789 | 10/10/2026 |
| 2.0.12 | 1,296 | 9/30/2026 |
| 2.0.11 | 856 | 9/28/2026 |
| 2.0.10 | 1,234 | 9/26/2026 |
| 2.0.9 | 1,137 | 9/21/2026 |
| 2.0.8 | 258 | 9/20/2026 |
| 2.0.7 | 216 | 9/20/2026 |
| 2.0.6 | 870 | 9/19/2026 |
| 2.0.5 | 427 | 8/10/2026 |
| 2.0.4 | 129 | 8/9/2026 |
| 2.0.3 | 117 | 8/8/2026 |
| 2.0.2 | 111 | 8/7/2026 |
| 2.0.0 | 122 | 8/7/2026 |
| 1.7.2 | 239 | 8/6/2026 |
| 1.7.1 | 348 | 6/11/2026 |
| 1.7.0 | 121 | 5/18/2026 |
| 1.6.0 | 115 | 5/13/2026 |