Design judgement on structs versus classes: semantics first, size and copying, allocation and GC, boxing risks, readonly and ref structs, and measuring with BenchmarkDotNet and allocation profiling.
Default to a class. Choose a struct when the type represents a single value (an ID, coordinate, money amount, time span), is small (around 16 bytes is the usual guideline), is immutable, is rarely boxed, and you create many of them so avoiding heap allocations matters. Use readonly struct or readonly record struct, and ref struct only for stack-only views like spans. Structs cost copies on every assignment and call and can box silently; classes cost an allocation, an object header and GC work, and add pointer indirection. Prove it with BenchmarkDotNet ([MemoryDiagnoser]), allocation profiles and production GC metrics rather than intuition.
My default is a class. I choose a struct for small, immutable, value-like types where avoiding allocation actually matters — and I prove the choice with measurements, because structs trade allocation for copying and boxing risks. I'd go through the criteria, the costs on each side, my recommended defaults, and how I measure.
All (or nearly all) should hold:
Money, Coordinate, OrderId, DateRange.readonly struct).object, not passed through non-generic interfaces, not in non-generic collections.default(T), all zeros, is valid or detectable).If the type has identity, a lifecycle, polymorphism needs (inheritance), or is large/mutable — class.
| Cost | Class | Struct |
|---|---|---|
| Allocation | Heap allocation per instance (cheap bump pointer, but adds GC work) | None by itself (inline in its container) |
| GC | Each instance tracked; survivors promoted and traced | No per-instance GC tracking (unless boxed or inside a heap object) |
| Copying | Copy a reference (8 bytes) | Copy all bytes on every assignment, argument, return |
| Boxing | N/A | Allocation + copy whenever converted to object/interface |
| Memory overhead | 16-byte header + MethodTable pointer + padding | None beyond fields |
| Arrays | Array of references → objects scattered on heap | Contiguous data, cache-friendly |
| Equality | Reference by default | Field-wise (slow via reflection unless IEquatable<T>/record struct) |
| Mutation bugs | Shared mutation through aliases | Lost updates on copies if mutable |
| Null | Can be null | Can't (use T?, which adds size) |
readonly record struct for strongly typed IDs (OrderId(Guid)), small value objects (Money if decimal + small currency code, Coordinate), and keys in hot dictionaries.record (class) for DTOs and larger value objects — value semantics without copy cost.ref struct only for stack-only buffer types (spans, parsers).[MemoryDiagnoser]: compare class vs struct for the real operations (creating, storing in collections, passing through the call chain, dictionary lookups). Look at Mean, Allocated and Gen0 columns.dotnet-trace gc-verbose) — a struct showing up as an allocated type means it's being boxed.in (with readonly struct), and look at the JIT disassembly for big movdqu/memcpy sequences.dotnet-counters (gc-heap-alloc-rate, % time in GC, gen-0/1/2 counts), latency percentiles from load tests (k6, NBomber), and Application Insights in staging.[In my case] a geospatial service created [~50 million] Coordinate objects per batch as a class (two doubles: 16 bytes of data + 16 bytes of header + 8-byte reference). A dotMemory snapshot showed them as the top allocated type and GC took [~25%] of batch time. I changed Coordinate to a readonly record struct, stored them in arrays/spans, and ran BenchmarkDotNet on the hot operations plus a full batch run: allocations dropped [~90%], GC time to [low single digits], and batch time fell [by ~40%]. One method still took IEquatable<object>-style parameters and boxed; the allocation profile caught it and we made it generic.
Checks that the candidate balances semantics and performance and relies on measurement.
Green flags: value-versus-identity first; the four or five criteria; costs table including header size, copying and boxing; recommended defaults; concrete measuring approach with BenchmarkDotNet and dotnet-counters; an example where a struct was the wrong choice.
Red flags: "structs are faster, use them everywhere"; mutable structs; no measurement; "structs are on the stack" as the only reason.
class Coordinate { double Lat; double Lon; }
heap object: [header 8][MT 8][Lat 8][Lon 8] = 32 bytes + 8-byte reference wherever stored
Coordinate[1_000_000] (class): 8 MB of references + 32 MB of objects = ~40 MB, scattered
readonly struct Coordinate { double Lat; double Lon; }
inline: [Lat 8][Lon 8] = 16 bytes
Coordinate[1_000_000] (struct): ~16 MB, contiguous
Plus, iterating the struct array reads memory sequentially (prefetch-friendly); iterating the class array chases pointers (cache misses).
in parameters avoid copies only for readonly structs (otherwise defensive copies appear on member calls).object parameters (string.Format, Console.WriteLine("{0}", x), logging message templates).IEquatable<object>/non-generic interfaces (IComparable).ArrayList, Hashtable, Dictionary<string, object>.ToString()/GetHashCode()/Equals(object) when not overridden.foreach over a struct collection via IEnumerable<T> boxes the struct enumerator.x is IFoo f on a struct.Each box costs ~24+ bytes and a copy.
[MemoryDiagnoser, DisassemblyDiagnoser(maxDepth: 2)]
public class CoordinateBench
{
private readonly double[] _lats = ..., _lons = ...;
[Benchmark(Baseline = true)] public double ClassVersion() { /* allocate + sum */ }
[Benchmark] public double StructVersion() { /* fill array + sum */ }
}
Run in Release, with realistic data sizes, and compare ratios rather than absolute numbers. Confirm with a production-like load test — micro-benchmarks can mislead when the real bottleneck is I/O.
HasConversion), not as entities.System.Text.Json: serialises structs fine; readonly record struct with constructor binding.default(OrderId) with Guid.Empty must be handled (validation, EF sentinel config).Coordinate allocations as class vs struct, then summing them.object and call it with the struct; observe allocations.in.dotnet-counters during a load test before and after the change.// Good struct: small, immutable, single value, used in bulk
public readonly record struct GeoPoint(double Lat, double Lon)
{
public static GeoPoint Origin => default; // all-zero default is meaningful
}
// Good struct: strongly typed ID, avoids mixing up Guids
public readonly record struct CustomerId(Guid Value);
// Better as a class: large, has identity, mutated over time
public sealed class Route
{
public Guid Id { get; } = Guid.CreateVersion7();
private readonly List<GeoPoint> _points = [];
public IReadOnlyList<GeoPoint> Points => _points;
public void Add(GeoPoint p) => _points.Add(p);
}
// Passing a medium struct without copying
public static double DistanceKm(in GeoPoint a, in GeoPoint b) { /* haversine */ return 0; }
// Avoid boxing in generic code
public static bool AllEqual<T>(ReadOnlySpan<T> items) where T : IEquatable<T>
{
for (int i = 1; i < items.Length; i++)
if (!items[i].Equals(items[0])) return false; // constrained call, no box
return true;
}
What happens: classes converted to structs "for performance". Why it is wrong: boxing and copying can make code slower and more complex. Do instead: benchmark allocations, throughput and latency before and after.
What happens: 100-byte structs passed around. Why it is wrong: copy costs dominate. Do instead: keep structs small or use classes/records.
What happens: structs with setters used in collections.
Why it is wrong: lost updates on copies.
Do instead: readonly struct, return new values.
What happens: structs flow through object or interfaces.
Why it is wrong: allocations reappear, plus copies.
Do instead: use generics with constraints; verify with allocation profiling.
What happens: structs used as dictionary keys rely on ValueType.Equals.
Why it is wrong: slow reflection-based equality and poor hashing.
Do instead: use readonly record struct or implement IEquatable<T>.
What happens: domain entities as structs. Why it is wrong: no identity semantics, no inheritance, copies everywhere. Do instead: classes for entities.
What happens: default(Money) with a null currency flows into calculations.
Why it is wrong: invalid states bypass constructors.
Do instead: validate at boundaries; make defaults valid or detectable.
What happens: a 10× micro-benchmark win assumed to translate to production. Why it is wrong: real bottlenecks may be I/O; improvements may be negligible end to end. Do instead: confirm with load tests and runtime counters.
Seniors keep class as the default and treat structs as a measured optimisation or a semantic choice for values.
| Situation | Senior choice |
|---|---|
| Domain value with no identity, small | readonly record struct |
| Bulk numeric or telemetry data | readonly struct in arrays/spans, benchmarked |
| View over memory | ref struct |
| Entity, service, DTO | Class or record class |
| Medium struct on a hot path | in parameters, ref readonly returns, after measurement |
They always tie the decision to evidence: benchmark numbers for the hot path and production counters (allocation rate, % time in GC) before and after.
A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.
A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.
First I measure: Application Insights tells me which endpoint is slow and at what percentile, and whether time goes into dependencies like SQL or HTTP. Then I reproduce it on a Release build and run the Visual Studio Performance Profiler (Alt+F2): CPU Usage for hot paths, Database for query count and duration, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits. In an environment where I can't use Visual Studio, dotnet-counters shows live metrics like thread pool queue length and GC, and dotnet-trace captures CPU samples. I fix the biggest cause, often an N+1 query, re-measure against the baseline, and add a load test so it doesn't regress.
How to tell it (STAR)
**Situation:** our routing service spent [12%] of CPU in garbage collection. **Task:** cut GC overhead safely. **Action:** I profiled allocations, benchmarked a struct version of our point type and converted it. When a teammate converted a much larger type too, the benchmark showed a slowdown and we reverted it. We now require benchmarks for struct changes. **Result:** GC time fell from [12%] to [2%] and p99 latency improved by about [35%], with no regressions from over-eager struct use.
Last updated 30 Oct 2026.