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 ToString with no heap allocations.
dotnet add package 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 }"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 |
| ZeroAllocStruct_Equals | 2.8 ns | 0 B |
| CFE_GetHashCode | 38.7 ns | 88 B |
| ZeroAllocStruct_GetHashCode | 2.2 ns | 0 B |
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 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 records with any number of fields — which Vogen does not.
Full methodology and analysis: docs/performance.md
- Zero allocations — 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+
record |
ZeroAlloc.ValueObjects |
|
|---|---|---|
| Zero allocation | ✓ | ✓ |
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 |
[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.
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| 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: 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 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(sp => sp.GetRequiredService<IMachineIdProvider>().Id);If no provider is registered, Snowflake.New() falls back to ZA_SNOWFLAKE_WORKER_ID env var, then throws TypedIdException.
Install ZeroAlloc.ValueObjects.EfCore and register the convention:
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.AddTypedIdConventions();
}All [TypedId] structs in the DbContext's assembly are auto-mapped: Guid-backed → uniqueidentifier/uuid, long-backed → bigint. Per-property HasConversion still overrides.
No setup needed — the generator emits IParsable<T> + ISpanParsable<T>, so app.MapGet("/orders/{id}", (OrderId id) => …) just works.
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.
| 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 |
- 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.
| 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 |
MIT