Skip to main content

Performance

Identifiers tells you to prefer the struct form of an identifier, and argues it from memory layout. This page is the measurement behind that advice, including the two places where the measurement does not agree with it.

The benchmarks live in Benchmarks/DDDToolkit.Benchmarks and use BenchmarkDotNet. Run them yourself with:

dotnet build Benchmarks/DDDToolkit.Benchmarks/DDDToolkit.Benchmarks.csproj -c Release
Benchmarks/DDDToolkit.Benchmarks/bin/Release/net10.0/DDDToolkit.Benchmarks.exe --filter *

Both identifiers wrap a Guid and carry the same prefix, so nothing but the form differs:

[EntityId<Guid>("ORD")]
public readonly partial record struct StructOrderId;

[EntityId<Guid>("ORD")]
public partial record RecordOrderId
{
public static RecordOrderId From(Guid value) => new(value);
}

The machine​

Every number below came from one run on one machine. Treat the ratios as the result and the absolute times as trivia: yours will differ.

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9445/25H2)
12th Gen Intel Core i9-12900K 3.20GHz, 1 CPU, 24 logical and 16 physical cores
.NET SDK 10.0.301
.NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3
GC=Concurrent Workstation

The Entity Framework benchmarks run against SQLite in memory. That is the fastest database a benchmark can talk to, which is the point: it makes the share of a round trip that belongs to Entity Framework and to the identifier as large as it will ever be. On a real server across a network the same difference is smaller, never larger.

Creating identifiers​

10,000 identifiers built from values already in hand, into an array. RawGuid is the floor: the same array with no wrapper on it.

MeanAllocated
Guid42.89 μs156.29 KB
Struct id42.67 μs156.29 KB
Record id58.54 μs625.02 KB

The struct id is the raw Guid array, to the byte and inside the measurement error. The record id costs 4 times the memory: 64 bytes per identifier against 16, which is an 8-byte reference in the array plus a 56-byte object holding the object header, the Guid, the prefix reference and the two validation bookkeeping fields it inherits from ValueObject.

That is the same 64 bytes Identifiers counts. The two fields at the end are easy to forget: the record form carries the value object validation state, two fields per identifier that an identifier never uses.

The time difference here (1.37x) is mostly garbage collection, and it is the least reliable number on this page: the record run had a standard deviation of 13.8 μs against a mean of 58.5 μs, because 625 KB per operation puts the GC in the middle of the measurement.

Walking a collection​

10,000 identifiers in an array, compared against a target that matches the last element, so every comparison runs.

MeanRatioAllocated
Struct id, compare each3.37 μs1.00none
Record id, compare each8.46 μs2.51none
Struct id, read .Value7.23 μs2.15none
Record id, read .Value8.63 μs2.57none

Reading the value through a reference costs about 20%, which is the dereference the page predicts. Comparing costs 2.5 times, which is the same dereference paid twice plus the call.

This measurement used to read 69.6, with 1,440,000 bytes allocated. A record identifier inherited equality from ValueObject, which compares GetEqualityComponents() sequences:

return Enumerable.SequenceEqual(GetEqualityComponents(), other.GetEqualityComponents());

Two iterator objects, two boxed Guids and a LINQ call, for every comparison. That is a reasonable default for a value object with several components and absurd for an identifier wrapping one Guid, and nobody had measured it. The generators now emit a direct comparison of Value for entity ids and single value objects, which yields the same answer because both types have exactly one component. The numbers above are from after that change.

Reading a read-only collection​

order.Lines hands out a read-only view over the generated backing field. List<T>.AsReadOnly() is new ReadOnlyCollection<T>(this) and caches nothing, so a property written as => _lines.AsReadOnly() builds a wrapper on every read. The generator holds the view in a second field instead.

Sixteen reads of one property:

TimeAllocated
A wrapper per read68.2 ns384 B
The wrapper held in a field10.5 ns0 B
The bare list, no protection5.2 ns0 B

384 bytes is exactly 16 wrappers of 24. The element count does not change any of it: at 4 elements and at 256 the numbers are the same, because the wrapper wraps rather than copies.

The third row is not an option, only the floor. Returning _lines allocates nothing and satisfies IReadOnlyList<T>, but a caller can cast it back to List<T> and mutate the aggregate around every invariant it has. The 5 nanoseconds between the second row and the third are what that protection costs once it is no longer rebuilt per read.

Dictionary and set lookup​

1,000 hits against a container holding 10,000 entries.

MeanRatioAllocated
Dictionary keyed by struct id3.53 μs1.00none
Dictionary keyed by record id5.35 μs1.52none
HashSet of struct ids3.55 μs1.01none
HashSet of record ids4.92 μs1.40none

Every probe pays for a hash and at least one equality check, so this tracked the defect above exactly: it used to read 17.5 and 21.1, with 272,000 bytes allocated by something whose whole job is to be a fast index. With the direct comparison in place the record form costs about half as much again as the struct form, which is the dereference and the call, and allocates nothing.

Equality and hashing on their own​

One comparison and one hash, undiluted.

MeanAllocated
Struct id ==0.20 nsnone
Record id ==0.23 nsnone
Struct id GetHashCodenot measurablenone
Record id GetHashCode0.19 nsnone

Both forms are now effectively free, and the numbers here are at the edge of what the harness can resolve: BenchmarkDotNet reports the struct hash as "the method duration is indistinguishable from the empty method duration", so there is no honest figure to quote for it.

Before the generators emitted a direct comparison, the record form measured 30.47 ns and 144 bytes for an equality check and 23.81 ns and 128 bytes for a hash. Those two lines were the whole of the 17x-to-70x gap in the two sections above.

Text​

ToString and Parse, the operations at the edges of the system.

MeanAllocated
Struct id ToString()15.52 ns104 B
Record id ToString()19.18 ns104 B
Struct id Parse23.62 ns96 B
Record id Parse28.11 ns152 B

This one goes against the recommendation, and it is a real finding. The struct identifier's ToString allocates 2.2 times what the record identifier's does.

It is not the struct's fault, it is the generator's. The struct form emits:

public override string ToString()
=> IdPrefix.Length == 0 ? ValueToString() : IdPrefix + "_" + ValueToString();

private string ValueToString() => Convert.ToString(Value, CultureInfo.InvariantCulture) ?? string.Empty;

Convert.ToString(object, IFormatProvider) boxed the Guid and built the 36-character string, and the concatenation then built a second 40-character string: three allocations against the record form's one, which went through an interpolated string. A struct identifier written into a log line on every request paid 232 bytes for it.

The generator now writes the interpolated form too, pinned to the invariant culture:

public override string ToString()
=> IdPrefix.Length == 0
? string.Create(CultureInfo.InvariantCulture, $"{Value}")
: string.Create(CultureInfo.InvariantCulture, $"{IdPrefix}_{Value}");

Nothing is boxed, one string comes out, and both forms now allocate 104 bytes. The table above is from after that change.

The Entity Framework round trip​

The question is whether the form of the identifier matters to persistence at all. Identifiers says it does not, on the strength of this measurement. Both aggregates are the same shape, with the same payload column, keyed differently:

[AggregateRoot<StructOrderId>] public partial class StructOrder { ... }
[AggregateRoot<RecordOrderId>] public partial class RecordOrder { ... }

Inserting​

1,000 aggregates added and saved, against a database created fresh for each measured invocation.

MeanAllocated
Struct id30.26 ms ± 3.687.38 MB
Record id31.44 ms ± 4.157.71 MB

The times overlap. Do not read a winner into them: the error bars are ±12%, the distribution is bimodal, and a database write is not a thing you measure to three significant figures. The allocation figure is deterministic and says what there is to say: 4% more, which is the 1,000 identifier objects.

Reading​

2,000 rows in the table. "Read all" materialises every row through a new context; "find by key" does 100 single-row lookups.

MeanAllocated
Read all, struct id1.52 ms1090.03 KB
Read all, record id1.39 ms1293.20 KB
Find by key, struct id1.11 ms888.16 KB
Find by key, record id1.20 ms899.90 KB

The claim holds, with one correction to how it is phrased. The struct identifier costs nothing in persistence, and neither does the record identifier. Both go through the same generated value converter to the same provider column (the provider test suite asserts that the two produce the same column type), and the database work swamps everything either of them does. The 19% extra allocation on the record read is one object per row, and it disappears into the 1 MB that materialising 2,000 rows costs anyway.

If you were choosing between the two forms on persistence alone, there would be nothing to choose.

What this means for the recommendation​

Identifiers says to prefer the struct form. These are the reasons that recommendation has rested on, and what the measurements say about each:

ClaimVerdict
The struct is 16 bytes and the record adds a reference and a headerTrue, and more than it sounds: 64 bytes against 16, because the record also carries validation state.
A list of ten thousand is one block instead of ten thousand objectsTrue. Reading through the reference costs about 20%.
The struct costs nothing in persistenceTrue. So does the record: this is not a reason to choose either.
(unstated) Equality and hashingWas the strongest reason by far, at 17x to 70x with allocation on every comparison. Writing this page found the cause and it is fixed; the gap is now 1.4x to 2.5x with nothing allocated.
(unstated) ToStringWas the one place the record form won, because of a fixable inefficiency in the generated struct. Also fixed; both allocate 104 bytes.

The short version: choose the struct form to avoid an object per identifier and a dereference on every read, not for the database, and no longer because equality is catastrophic. Two of the five rows in this table describe defects that existed only because nobody had measured, which is the case for keeping the benchmarks in the repository rather than running them once.

Provider coverage​

The benchmarks above are SQLite only. Correctness on other providers is a separate question, and it is answered by a separate suite: Tests/DDDToolkit.EntityFramework.Providers.Tests runs the mapping, concurrency, outbox and inbox tests against real PostgreSQL 17 and SQL Server 2022 in Testcontainers. Without Docker every test in it skips with a message naming the reason, so a machine without Docker stays green and stays honest about it.

Three things that suite pins down and SQLite never could:

  • The ddd schema is real. SQLite has no schemas and silently drops the argument, so the default the outbox and inbox ship with was never exercised. It works: EnsureCreated creates the schema and both tables land in it on both servers.
  • A struct id and a record id produce the same column. uuid on PostgreSQL, uniqueidentifier on SQL Server, for the key, for a plain property and for a nullable one. This is what the persistence numbers above are measuring the other half of.
  • The outbox timestamp conversion costs SQL Server and not PostgreSQL. The outbox stores every timestamp as a UTC DateTime because SQLite cannot order by DateTimeOffset. On PostgreSQL both land in timestamp with time zone, so the workaround is free. On SQL Server the column is datetime2 where a DateTimeOffset would have been datetimeoffset: the offset is not stored, which is harmless while everything is UTC and is still a column shape SQL Server users did not choose.

A fourth is a difference rather than a cost: a primitive collection of converted ids becomes a native integer[] on PostgreSQL and a JSON nvarchar on SQL Server. Both round trip, and a query that reaches inside one will not port.

Things this page does not measure​

  • Other providers. Everything here is SQLite in memory. Mapping behaviour on PostgreSQL and SQL Server is covered by the suite above, but nothing times it.
  • The generators. Build-time cost of the source generators is not measured anywhere.
  • The outbox. Throughput of OutboxProcessor under load, and what the ProcessedAt index does as the table grows, are open questions.
  • JSON and GraphQL. The generated converters are not benchmarked.
  • Value objects. ValueObject equality has the same GetEqualityComponents cost measured above, and value objects are compared far less often than identifiers, but nobody has checked.