You already know a struct is copied and a class is shared. The advanced question is: copied how much, shared how dangerously, and when does that choice come back to bite you?
You've known since Foundations that int is a value type and Customer is a reference type. That's table stakes. But here's a question that trips up developers with years of C# experience: what exactly gets copied when you pass a 6-field struct to a method? All six fields, every time, on every call — even if you only read one of them. And here's a second one: why does myList[0].Balance += 10; sometimes silently do nothing at all, compiling cleanly and changing nothing?
Both questions have the same root cause — a mutable struct interacting with the language's copy semantics in a way that looks fine and isn't. This lesson revisits value vs. reference types at the depth a production codebase actually demands: what "copy" really means for a large struct, why mutable structs are a classic source of silent bugs, how readonly struct and in parameters address both, and how to make the struct-vs-class decision like someone who has been burned by it before.
In this lesson, you'll go past "value types copy, reference types share" into the mechanics that actually matter in real code: full-copy semantics on every assignment and parameter pass, the mutable-struct trap, readonly struct, a preview of in parameters, and a genuinely useful decision framework for struct vs. class.
A value type (a struct, including all the built-in numeric types) stores its data inline, wherever the variable itself lives. A reference type (a class) stores its data on the heap, and the variable holds a pointer to it. You know this. What this lesson adds is precision about what "copy" costs and what mutability does to that copy.
Every value-type assignment, parameter pass, and return performs a full bitwise copy of every field, not just "the value." A struct with 8 decimal fields is 128 bytes — assign it, pass it to a method, return it from a method, put it in an array and iterate with a value-typed loop variable, and each of those operations copies all 128 bytes. A reference-type assignment, by contrast, always copies exactly one pointer-sized value (8 bytes on a 64-bit runtime) — regardless of whether the object behind it is 16 bytes or 16 megabytes.
Value semantics (full copy) and reference semantics (shared object) are each internally consistent. The trouble starts when a mutable struct is used somewhere the language silently hands you a copy instead of the original — because now "mutating" it does nothing observable, and there's no compiler warning telling you so.
public struct Point { public int X { get; set; } public int Y { get; set; } }
public class Sprite
{
public Point Position { get; set; } // a mutable struct exposed via a property
}
var sprite = new Sprite();
sprite.Position.X = 10; // does NOT compile — and that's the language protecting you here
C# actually refuses to compile sprite.Position.X = 10;, precisely because Position's getter would hand back a temporary copy of the struct, and mutating a copy that's about to be discarded is almost certainly not what you meant. But the moment you introduce a local variable, the same trap becomes perfectly legal — and silently wrong:
Point p = sprite.Position; // p is a COPY of the struct
p.X = 10; // this mutates the COPY only
// sprite.Position.X is still whatever it was before — p was never "the real thing"
Developers needed a way to declare "this struct's fields never change after construction," so that the compiler — and every developer reading the code — can trust that a copy of the struct is behaviorally interchangeable with the original in every way that matters, closing off this entire category of "I mutated a copy by accident" bug at the design level rather than relying on programmer discipline.
readonly structA readonly struct makes every field/auto-property implicitly readonly/get-only, enforced by the compiler at the type declaration itself. If it genuinely cannot be mutated after construction, the entire "mutated a throwaway copy" failure mode simply cannot occur — there's nothing left to mutate.
public readonly struct Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) { X = x; Y = y; }
public Point WithX(int x) => new Point(x, Y); // "modify" by returning a new value
}
Point p2 = p1; — assignmentMove(p1); — passing as a method argument (no ref/in)return p1; — returning from a methodforeach (var p in points) — each loop iterationobject boxed = p1; — boxing (copies onto the heap, plus allocation)
A reference type does none of this copying — every one of these operations copies a single pointer instead.
public struct Vector2 { public float X; public float Y; }
Vector2[] path = new Vector2[100];
foreach (var step in path)
{
step.X += 1; // mutates the LOOP VARIABLE's copy — path is untouched
}
foreach's iteration variable is a fresh copy of each element every time (and C# even forbids assigning to it directly for exactly this reason on mutable structs in some contexts) — no error, no warning, just no effect.for (int i = 0; i < path.Length; i++)
{
path[i].X += 1; // mutates the ACTUAL array element in place — this works
}
foreach-variable mutation doesn't) is itself a common source of confusion.public readonly struct Vector2
{
public float X { get; }
public float Y { get; }
public Vector2(float x, float y) { X = x; Y = y; }
public Vector2 Add(float dx) => new Vector2(X + dx, Y);
}
for (int i = 0; i < path.Length; i++)
path[i] = path[i].Add(1); // explicit replacement — impossible to "forget" the assignment
readonly, there's no in-place mutation to accidentally lose — every "change" is an explicit new value you must assign somewhere, which makes the bug class structurally impossible rather than merely avoided by convention.in parametersPassing a large struct to a method by value copies every field on every call. If the method only needs to read the struct, that copy is pure overhead. The in modifier passes the struct by reference internally (avoiding the copy) while the compiler still enforces that the method cannot modify it — you get the performance of pass-by-reference with the safety of pass-by-value. (Full ref/in/out/ref struct mechanics — including the exact rules for when in actually helps versus when it's a wash — are covered in depth later, in Part V of this Advanced tier. For now, treat this as a named tool worth reaching for.)
public readonly struct Matrix4x4 // 16 floats — 64 bytes
{
// ...16 float fields/properties...
public readonly float M11, M12, M13, M14;
// ...
public float Determinant() => /* reads fields, computes a value */ 0f;
}
// Without 'in': every call copies all 64 bytes onto the stack for the parameter
float ComputeA(Matrix4x4 m) => m.Determinant();
// With 'in': the method receives a reference to the caller's struct — no copy —
// and the compiler prevents ComputeB from modifying it through that reference
float ComputeB(in Matrix4x4 m) => m.Determinant();
Meaning: in is purely a calling-convention optimization for read-only access to a struct — it changes nothing about the method's outward behavior, only how the parameter is physically passed underneath.
A payment system modeling money as a mutable struct is a realistic setting for exactly the bug this lesson is about — and a realistic place to see why readonly struct earns its keep.
// Before: mutable struct — an accident waiting to happen
public struct MoneyMutable
{
public decimal Amount { get; set; }
public string Currency { get; set; }
}
public class Invoice
{
public MoneyMutable Total { get; set; }
}
void ApplyDiscount(MoneyMutable amount, decimal percent)
{
amount.Amount -= amount.Amount * percent; // mutates the PARAMETER's copy only
}
var invoice = new Invoice { Total = new MoneyMutable { Amount = 100m, Currency = "USD" } };
ApplyDiscount(invoice.Total, 0.10m);
Console.WriteLine(invoice.Total.Amount); // 100 — the "discount" never actually applied. Silent bug.
// After: readonly struct — the bug becomes impossible to write
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency) { Amount = amount; Currency = currency; }
public Money ApplyDiscount(decimal percent) => new Money(Amount - Amount * percent, Currency);
}
public class InvoiceV2
{
public Money Total { get; set; }
}
var invoiceV2 = new InvoiceV2 { Total = new Money(100m, "USD") };
invoiceV2.Total = invoiceV2.Total.ApplyDiscount(0.10m); // explicit reassignment — you SEE the change happen
Console.WriteLine(invoiceV2.Total.Amount); // 90 — correct, and impossible to get "silently" wrong this way
Notice the shape of the fix: readonly struct doesn't just prevent a bug, it forces the "modify" operation to become an explicit assignment at the call site — the exact same non-destructive pattern records use with with expressions, and for the same underlying reason.
A value type is a photocopy of a document. Hand someone a photocopy, and whatever they scribble on it never touches your original — that's exactly the safety value types give you. But if the "document" is 200 pages long, photocopying it on every handoff is real, measurable work, even if the person receiving it only ever reads page one.
A reference type is a shared whiteboard. Everyone holding "a reference to the whiteboard" is really just holding directions to the same physical board — write on it from any desk, and everyone sees the change immediately. Cheap to hand around (you're just passing directions, not the board), but dangerous if you forget that everyone else's directions point at the same surface you're erasing.
A mutable struct is the worst of both: it looks and behaves like a photocopy (so people write on it expecting a private copy), but the moment you scribble on the wrong photocopy — a temporary one the runtime handed you and is about to throw away — your edit vanishes with it. readonly struct removes the pen from your hand entirely: you can't scribble on the photocopy at all, only make a brand-new one with the changes baked in.
readonly field or an in parameter of a struct that is not itself declared readonly, the compiler can't prove the member won't mutate it — so it silently generates a full copy first, calls the member on the copy, and discards the copy. This is a correctness safeguard, but it's also a hidden performance cost: code that looks like a cheap read-only call can be quietly copying dozens or hundreds of bytes on every invocation.readonly struct eliminates this entirely — the compiler can prove no member mutates anything, so no defensive copy is ever needed.IComparable, IEquatable<T> accessed polymorphically, etc.) or assigned to object gets boxed: the runtime allocates a new object on the heap, copies the struct's fields into it, and hands back a reference to that box.where T : struct and calling only members declared directly on T avoids boxing — this is one of the concrete performance reasons generics (covered in Intermediate) exist at all, rather than writing everything against object.in parameter is passed under the hood as a managed pointer to the caller's storage — mechanically similar to ref, but the compiler enforces read-only access at every use site inside the method, and (per point 1) will insert a defensive copy automatically if the struct isn't readonly and the method calls a non-readonly member on it.A small struct (a couple of primitive fields) that's read far more often than it's passed around is typically cheaper than the equivalent class, because it avoids heap allocation and GC tracking entirely. A large struct copied constantly, or a struct used through an interface (boxing), can easily be slower than a class. "Struct" is not a synonym for "fast" — it's a trade-off that depends on size and usage pattern.
readonly on a field vs. readonly struct on the type — different guaranteesA readonly field on a struct prevents that field from being reassigned after construction — but it does not stop you from calling a mutating method on the struct stored in that field (which is exactly why the compiler resorts to defensive copies, described above). Declaring the entire type readonly struct is the guarantee that removes the ambiguity: nothing in the type can mutate state, full stop, so no defensive copy is ever necessary.
in is not "pass by reference" in the ref sensein lets the callee read the caller's data without copying it, but the callee can never write through it — that's an entirely different contract from ref, which allows the callee to modify the caller's variable. Conflating the two is a common early mistake once developers first meet in; the deep-dive lesson on ref/in/out covers this distinction fully.
Writing { get; set; } auto-properties on a struct because it's less typing than a constructor and an init/read-only property.
Default to readonly struct for any new value type. The extra few lines for a constructor are far cheaper than debugging a "mutation that didn't stick" bug months later.
A struct with 10+ fields, or fields that are themselves large or reference-typed, copied frequently through method calls and collections.
Above roughly 16 bytes (a rough, commonly-cited guideline, not a hard rule — measure if it matters), the copy cost starts to outweigh the allocation savings; prefer a class (or a record) once a value type stops being small.
public readonly struct Score : IComparable<Score> { /* ... */ public int CompareTo(Score other) => 0; }
IComparable<Score> boxed = someScore; // boxes — allocates on the heap right here
List<IComparable<Score>> scores = []; // every element added this way is boxed
Keep struct usage generic and concrete (List<Score>, methods with where T : struct, IComparable<T>) rather than routing it through a non-generic interface reference — this is precisely the boxing trap described in "Under the Hood."
readonly struct.object — boxing would erase most of a struct's benefit anyway.readonly struct specifically, and deliberately, when the type is small, genuinely immutable, and you have a concrete reason (measured or well-understood) to avoid heap allocation. A mutable struct should be a rare, carefully justified exception, not a default.
foreach variable, a method parameter) affects nothing you can see.readonly struct removes the possibility entirely by making the type immutable, enforced by the compiler.in parameters pass a large struct by reference internally while keeping it read-only from the callee's side — a preview of a topic covered fully later.You've gone past "struct copies, class shares" into what that copying actually costs and where mutability turns it into a bug. Let's check your understanding.
1. A foreach (var item in structArray) { item.Value = 5; } loop over an array of a mutable struct type compiles but has no visible effect on the array. Why?
Correct: B
Why B is correct: Each pass through a foreach loop over value-typed elements hands you a copy, not the underlying storage. Mutating that copy is legal but invisible to anyone else — exactly the trap described in this lesson.
Why A is incorrect: foreach works correctly and as documented; the surprise is entirely about value-copy semantics, not a defect.
Why C is incorrect: Making the field static would change its meaning entirely (one shared value across all instances) and isn't related to this behavior.
Why D is incorrect: Structs are stored perfectly normally in arrays — that's actually one of their main performance advantages (compact, contiguous storage).
Reinforcement: Indexing directly (structArray[i].Value = 5;) mutates the real element; the foreach variable never does.
2. What does declaring a type as readonly struct actually guarantee?
Correct: B
Why B is correct: readonly struct is a type-level guarantee of immutability, enforced at compile time. Because nothing in the type can mutate its own state, the compiler never needs to protect a caller with a defensive copy before calling a member — as covered in "Under the Hood."
Why A is incorrect: readonly struct instances are passed exactly like any other struct — by value (or by in/ref if specified).
Why C is incorrect: Boxing only happens when a struct is used through an interface reference or as object — readonly has nothing to do with triggering it, and in fact reduces unrelated overhead (defensive copies).
Why D is incorrect: It remains a value type with full copy semantics on assignment — readonly only removes mutability, it doesn't change the type category.
Reinforcement: readonly struct is about proving immutability to the compiler, which eliminates both a bug class and a hidden performance cost (defensive copies) at once.
3. You have a 64-byte struct that a method only needs to read, called millions of times in a hot loop. Which parameter approach avoids the per-call copy while still preventing the method from modifying the caller's data?
Correct: C
Why C is correct: in passes the struct by reference under the hood, avoiding the 64-byte copy, while the compiler still enforces read-only access inside the method — exactly the combination this scenario calls for.
Why A is incorrect: Passing normally copies all 64 bytes on every one of the millions of calls — real, measurable overhead in a hot path.
Why B is incorrect: ref avoids the copy too, but it also allows the method to modify the caller's data — more permission than this scenario needs or wants.
Why D is incorrect: That's a valid alternative design (a class copies just a pointer), but it's a bigger change than necessary, and changes the type's semantics (reference vs. value) for the whole codebase, not just this one call site.
Reinforcement: in exists specifically for this scenario — read-only access to a struct, without paying the copy cost.
4. Why can using a small readonly struct through an interface reference (e.g. IComparable<T>, accessed non-generically) end up slower than just using a class?
Correct: B
Why B is correct: As explained in "Under the Hood," treating a struct as its interface type triggers boxing — a real heap allocation plus a copy of the struct's data into that allocation. For a type chosen specifically to avoid heap allocation, this defeats the purpose.
Why A is incorrect: Structs fully support implementing interfaces; the cost issue is specifically about accessing them through an interface-typed reference, not about whether they can implement one.
Why C is incorrect: readonly struct types can implement interfaces normally — immutability and interface implementation are unrelated concerns.
Why D is incorrect: The overhead here is specifically the boxing allocation and copy, not some blanket rule that interface calls are inherently slow — generic code constrained to the concrete struct type avoids boxing and the overhead entirely.
Reinforcement: Boxing is the concrete mechanism by which "small struct, chosen for performance" can quietly become "heap allocation on every use" — know where it happens so you can avoid it in hot paths.
You can now reason about value vs. reference types at the level a production codebase actually demands — copy cost, mutability traps, and the real risks of getting the choice wrong.
dotnetmadeeasy.com — Learn C# and .NET, the right way.