📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Struct or class: how do you decide in C#, and how do you prove the choice was right? Explain the criteria, the costs on each side (copying, boxing, allocation, GC), the defaults you would recommend, and how you would measure the impact.

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.

Senior Architecture Advanced benchmarking class csharp design gc oop performance struct

🎯 Key points

  • Default to classes; choose structs for small, immutable, single-value types used in large numbers
  • Value semantics must fit: anything with identity or shared state must be a class
  • Classes cost an allocation, a 16-byte header on 64-bit and GC work; structs cost copies and boxing risk
  • Keep structs around 16 bytes (up to about 32 with in-parameters) and make them readonly
  • Use readonly record struct for IDs and value objects, ref struct for stack-only views
  • Default(T) skips constructors, so the all-zero value must be valid
  • Avoid boxing with generics and constraints; implement `IEquatable<T>` or use record structs
  • Prove the choice with BenchmarkDotNet [MemoryDiagnoser], allocation profiling and production GC counters

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Criteria for a struct

All (or nearly all) should hold:

  1. Represents a single value semantically (like a primitive): Money, Coordinate, OrderId, DateRange.
  2. Small: ideally ≤ 16 bytes; up to ~32–64 bytes can be justified with benchmarks.
  3. Immutable (readonly struct).
  4. Rarely boxed: not stored as object, not passed through non-generic interfaces, not in non-generic collections.
  5. Value equality makes sense (two equal values are interchangeable).
  6. Has a sensible default (default(T), all zeros, is valid or detectable).
  7. Created in large numbers or on hot paths (arrays of millions, per-request allocations at high RPS).

If the type has identity, a lifecycle, polymorphism needs (inheritance), or is large/mutable — class.

2. Costs on each side

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)

3. Defaults I recommend

  • Classes for entities, services, DTOs with many fields, anything polymorphic.
  • 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).
  • Never mutable structs in public APIs.

4. How I prove the choice was right

  1. Micro-benchmark with BenchmarkDotNet and [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.
  2. Check for boxing: allocation profiles (Visual Studio .NET Object Allocation tool, dotMemory, dotnet-trace gc-verbose) — a struct showing up as an allocated type means it's being boxed.
  3. Check copy costs: if the struct is larger than 16 bytes, benchmark passing it by value vs in (with readonly struct), and look at the JIT disassembly for big movdqu/memcpy sequences.
  4. Measure end-to-end under realistic load: 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.
  5. Decide with data: keep the struct only if allocation/GC drops and throughput/latency improve without regressions elsewhere. Revert if boxing or copying ate the gains.

5. A production example (how I'd tell it)

[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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Where the bytes go

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).

2. Copying cost model

  • ≤ 16 bytes: often passed and returned in registers (x64 SysV ABI uses two registers for many 16-byte structs; Windows x64 passes >8-byte structs by hidden reference — the JIT optimises copies, but details vary by platform).
  • Larger structs: copied via stack memory on each call/assignment. The JIT can elide some copies (struct promotion, copy propagation), but complex code paths keep them.
  • in parameters avoid copies only for readonly structs (otherwise defensive copies appear on member calls).

3. Hidden boxing checklist

  • Passing to object parameters (string.Format, Console.WriteLine("{0}", x), logging message templates).
  • Using IEquatable<object>/non-generic interfaces (IComparable).
  • Storing in ArrayList, Hashtable, Dictionary<string, object>.
  • Calling ToString()/GetHashCode()/Equals(object) when not overridden.
  • foreach over a struct collection via IEnumerable<T> boxes the struct enumerator.
  • Pattern matching x is IFoo f on a struct.

Each box costs ~24+ bytes and a copy.

4. GC interaction

  • Structs inside classes or arrays live on the heap with their container; they don't add separate objects, but if they contain references, the GC scans those fields.
  • Structs with no references in arrays let the GC skip scanning the array contents entirely — a big win for large numeric arrays.
  • Large struct arrays (≥ 85 KB) go to the LOH — allocate once and reuse.

5. BenchmarkDotNet setup

[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.

6. Structs and frameworks

  • EF Core: structs work as complex types/value converters (e.g. strongly typed IDs with HasConversion), not as entities.
  • System.Text.Json: serialises structs fine; readonly record struct with constructor binding.
  • DI: services must be classes.
  • Default values: default(OrderId) with Guid.Empty must be handled (validation, EF sentinel config).

Try it yourself

  1. Benchmark 1 million Coordinate allocations as class vs struct, then summing them.
  2. Add a method taking object and call it with the struct; observe allocations.
  3. Grow the struct to 64 bytes and benchmark passing by value vs in.
  4. Run dotnet-counters during a load test before and after the change.

💻 Code

// 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;
}

⚠ Common mistakes

1. Choosing structs without measurement

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.

2. Large structs

What happens: 100-byte structs passed around. Why it is wrong: copy costs dominate. Do instead: keep structs small or use classes/records.

3. Mutable structs

What happens: structs with setters used in collections. Why it is wrong: lost updates on copies. Do instead: readonly struct, return new values.

4. Ignoring boxing paths

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.

5. Structs without equality members

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>.

6. Structs for entities or polymorphic types

What happens: domain entities as structs. Why it is wrong: no identity semantics, no inheritance, copies everywhere. Do instead: classes for entities.

7. Forgetting the default value

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.

8. Trusting micro-benchmarks alone

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions readonly structs and defensive copies Why: Checks struct feature depth
    How to answer

    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.

  2. When: Candidate explains value versus reference semantics Why: Confirms the underlying type model
    How to answer

    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.

  3. When: Candidate says they would measure allocations Why: Probes profiler usage
    How to answer

    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.

🛠 Real-world problems: how you handle them

Measured struct conversion: one win and one revert
Situation
A geospatial routing service spent [12%] of CPU in GC with an allocation rate of about [1.6 GB/s].
Signals
dotnet-counters showed frequent gen-0 collections and p99 latency spikes during peak routing requests.
Investigation
Allocation profiling showed `GeoPoint` (a small class) as the top allocated type. BenchmarkDotNet compared class and `readonly record struct` versions of the hot distance loop.
Resolution
Converted `GeoPoint` to a 16-byte `readonly record struct`; allocation fell by about [85%] and time in GC to [2%]. A follow-up change converting the 120-byte `Route` to a struct made benchmarks [30%] slower due to copying, so it was reverted.
Prevention
Rule: any struct conversion needs a benchmark in the PR showing time and allocations; benchmarks for hot paths run nightly and alert on regressions.

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.

↩ Usually asked after

More in Design Judgment

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me