aths is 3β5x, not marginal. Architects who map type selection to payload size and operation frequency consistently reduce memory allocation rates by 30β70% in message processing and event sourcing pipelines.
Core Solution
Implementing a robust records-and-value-types strategy requires explicit topology mapping, compiler directive usage, and equality contract enforcement. Follow this implementation sequence:
Step 1: Define Semantic Boundaries
Determine whether your type represents identity or value.
- Identity: Objects with lifecycle, references, or external tracking (e.g.,
User, Order, DbContext). Use class or record.
- Value: Data carriers where equality depends on content, not reference (e.g.,
Money, Coordinate, EventPayload). Use record struct or struct.
Step 2: Apply Size Thresholds
The CLR optimizes value type passing based on register capacity and ABI calling conventions.
- β€ 16 bytes: Safe for
record struct. Fits in two 64-bit registers. Zero-copy passing in most architectures.
- 17β32 bytes: Evaluate usage.
record struct is acceptable if passed by in or ref. Avoid return-by-value in hot paths.
- > 32 bytes: Use
record or class. Copy overhead outweighs allocation benefits. Consider readonly struct with explicit GetHashCode overrides if immutability is required.
Step 3: Implement Compiler-Optimized Records
Default record syntax generates Equals, GetHashCode, PrintMembers, and the Clone method. Leverage it correctly:
// Reference record: heap-allocated, value-based equality
public record UserProfile(string Id, string Email, DateTimeOffset CreatedAt)
{
// Non-destructive mutation preserves immutability contract
public UserProfile WithEmail(string newEmail) => this with { Email = newEmail };
}
// Value record: stack-allocated, bitwise equality, no inheritance
public readonly record struct Money(decimal Amount, string Currency)
{
// Custom equality only when semantic rules differ from field comparison
public static bool operator ==(Money left, Money right) =>
left.Amount == right.Amount && left.Currency == right.Currency;
public static bool operator !=(Money left, Money right) => !(left == right);
}
Step 4: Enforce Immutability Contracts
Records guarantee shallow immutability. Reference fields inside records remain mutable. Mitigate this:
public record AuditLog(string UserId, IReadOnlyList<string> Actions);
// Usage: Pass defensive copies or immutable collections
var log = new AuditLog("u-42", Array.Empty<string>());
For record struct, apply readonly to prevent accidental mutation and enable compiler optimizations:
public readonly record struct Point3D(double X, double Y, double Z);
Step 5: Benchmark Equality Hot Paths
Use BenchmarkDotNet to validate assumptions. Equality performance dictates type choice in filtering, dictionary keying, and event deduplication.
[MemoryDiagnoser]
public class EqualityBenchmarks
{
private readonly RecordDto _record = new(1, "test", 3.14);
private readonly StructDto _struct = new(1, "test", 3.14);
[Benchmark]
public bool RecordEquals() => _record.Equals(new RecordDto(1, "test", 3.14));
[Benchmark]
public bool StructEquals() => _struct.Equals(new StructDto(1, "test", 3.14));
}
Architecture Decisions and Rationale
- Prefer
record for API boundaries: JSON serialization, ORM mapping, and inter-service contracts benefit from reference semantics and built-in ToString/PrintMembers.
- Prefer
record struct for internal pipelines: Event processing, math operations, and state machines avoid GC pauses and improve cache locality.
- Avoid
init setters on reference fields: They break structural immutability. Use constructor initialization or factory methods.
- Leverage
with expressions sparingly: They allocate a new instance. In tight loops, prefer in-place mutation on structs or object pooling for records.
Pitfall Guide
1. Assuming record is a Value Type
Default record compiles to a reference type. It lives on the heap, participates in garbage collection, and compares by reference unless Equals is overridden. Teams that treat it as a stack type encounter unexpected allocations in high-throughput scenarios.
Fix: Use record struct for value semantics. Reserve record for identity-based or DTO scenarios.
2. Using record struct for Large Payloads
Structures over 32 bytes trigger hidden copying during method returns, async state machine captures, and LINQ projections. The CLR cannot always optimize away these copies, leading to stack pressure and degraded throughput.
Fix: Benchmark payload size. Switch to record or class when field count exceeds 3β4 primitives or includes reference types.
3. Breaking Immutability with Mutable References
A record containing List<T> or Dictionary<TKey, TValue> appears immutable but allows internal mutation. This violates value semantics and causes subtle bugs in caching or event sourcing.
Fix: Wrap mutable collections in IReadOnlyCollection<T> or use ImmutableArray<T>/ImmutableDictionary<TKey, TValue>.
4. Overriding Equals Incorrectly in Records
Records auto-generate Equals based on positional parameters. Manual overrides that ignore compiler-generated contracts cause inconsistent behavior in dictionaries, sets, and with expressions.
Fix: Never override Equals unless you also override GetHashCode and understand the compiler's field traversal order. Prefer composition over inheritance for equality customization.
5. Misusing with in Hot Paths
The with expression creates a shallow copy. In loops processing millions of items, this generates proportional heap allocations. Teams report GC spikes when applying with to filter or transform streams.
Fix: Use record struct with in-place mutation, or switch to mutable class with object pooling for high-frequency transformations.
6. Ignoring Struct Inheritance Limitations
record struct cannot inherit from other types or implement base record behavior. Teams attempting to build hierarchies with value records hit compiler errors or resort to interfaces that force boxing.
Fix: Use composition or generics. If inheritance is required, switch to reference record or class.
7. Forgetting readonly on Value Records
Omitting readonly on record struct allows accidental mutation and disables certain JIT optimizations. The compiler cannot guarantee structural integrity, leading to defensive copies in generic constraints.
Fix: Always declare value records as readonly record struct unless explicit mutation is architecturally required.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| API DTO / JSON contract | record | Built-in serialization support, reference semantics, readable ToString | Low (standard heap allocation) |
| High-frequency event payload | record struct | Zero GC pressure, stack allocation, cache-friendly equality | Near-zero allocation cost |
| Domain entity with lifecycle | class or record | Identity tracking, ORM compatibility, reference equality | Standard allocation + tracking overhead |
| Math/geometry coordinate | readonly record struct | Bitwise equality, register-passed, no boxing | Optimal CPU/memory efficiency |
| Large configuration object (>5 fields) | record | Avoids copy overhead, supports with safely | Moderate allocation, predictable GC |
| Dictionary key / Set element | record struct (β€16B) or record | Value-based hashing without reference indirection | Hash computation dominates, not allocation |
Configuration Template
Copy this template into your project to standardize record and value type usage across layers:
// GlobalUsings.cs or equivalent
global using System.Collections.Immutable;
global using System.Runtime.CompilerServices;
// Architecture boundaries
namespace YourApp.Domain.ValueObjects
{
// Value record: stack-allocated, immutable, equality by content
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Zero => new(0m, "USD");
public static bool TryParse(string input, out Money result)
{
// Parse logic
result = default;
return false;
}
}
}
namespace YourApp.Application.DTOs
{
// Reference record: heap-allocated, identity/value hybrid, API-friendly
public record UserDto(
Guid Id,
string Email,
string DisplayName,
ImmutableArray<string> Roles,
DateTimeOffset LastLogin)
{
public UserDto WithRoles(IEnumerable<string> newRoles) =>
this with { Roles = newRoles.ToImmutableArray() };
}
}
namespace YourApp.Infrastructure.Messaging
{
// High-throughput event: value record, zero-allocation pipeline
public readonly record struct DomainEvent(
Guid AggregateId,
string EventType,
ImmutableArray<byte> Payload,
DateTimeOffset Timestamp);
}
Quick Start Guide
- Create a benchmark project: Run
dotnet new console -n TypeTopologyBenchmarks and install BenchmarkDotNet. Configure .NET 8 or later SDK.
- Define three variants: Implement
class, record, and record struct with identical fields (e.g., int Id, string Name, double Value). Apply readonly to the struct variant.
- Run equality and allocation tests: Use
[Benchmark] and [MemoryDiagnoser] to measure Equals() performance and Gen 0 collections over 1M iterations. Compare results against the WOW Moment table.
- Apply thresholds to your codebase: Replace hot-path DTOs with
record struct if β€16 bytes. Convert identity models to record if they require with expressions or API serialization.
- Validate in staging: Deploy to a staging environment with memory profiling enabled. Monitor allocation rates, GC frequency, and latency percentiles. Adjust type topology based on telemetry, not assumptions.