← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Quick Recap

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.

What Actually Gets Copied

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.

struct — what's copied

class — what's copied

Why Does It Exist?

The Problem — Mutability Plus Copying Is a Trap

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"

The Need

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.

The Solution — readonly struct

A 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
}

Big Picture

WHERE THE COPY HAPPENS — EVERY ONE OF THESE COPIES A STRUCT'S FULL FIELDS
Point p2 = p1; — assignment
Move(p1); — passing as a method argument (no ref/in)
return p1; — returning from a method
foreach (var p in points) — each loop iteration
object 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.

How It Works

FROM MUTABLE STRUCT BUG TO readonly struct FIX — STEP BY STEP
1. THE MUTABLE STRUCT — LOOKS FINE, ISN'T
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
}
2. WHY IT FAILS SILENTLY
3. THE FIX — MAKE THE INTENT EXPLICIT
for (int i = 0; i < path.Length; i++)
{
    path[i].X += 1; // mutates the ACTUAL array element in place — this works
}
4. THE BETTER FIX — DESIGN THE TYPE SO THIS CLASS OF BUG CAN'T HAPPEN
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

Simple Example

A preview of in parameters

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

Real-World Example

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.

Analogy

Photocopies vs. shared whiteboards

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.

Under the Hood

DEFENSIVE COPIES AND BOXING — WHERE THE RISK ACTUALLY LIVES
1. DEFENSIVE COPIES — THE COMPILER PROTECTING YOU FROM YOURSELF
2. BOXING — WHEN A VALUE TYPE HAS TO PRETEND TO BE A REFERENCE TYPE
3. HOW in PARAMETERS ARE ACTUALLY PASSED

Common Confusion

1. "Structs are always faster than classes" — only sometimes, and only if used correctly

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.

2. readonly on a field vs. readonly struct on the type — different guarantees

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

3. in is not "pass by reference" in the ref sense

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

Common Mistakes

Mistake 1 — Making a struct mutable "for convenience"

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.

Mistake 2 — Making a large data type a struct just because "it's just data"

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.

Mistake 3 — Using a struct through an interface in a hot path

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

When Should I Use It?

Choose a struct when:

Choose a class when:

Rule of thumb: If you're not sure, use a class — it's the invariant-free default. Reach for a 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.

Mental Model

struct = a full copy, every time — cheap if small, expensive if not, always independent.
class = one shared object, one pointer copied — cheap to pass, dangerous to mutate carelessly.
mutable struct = a copy that can be silently mutated and thrown away — the worst of both worlds.
readonly struct = a copy that literally cannot be silently mutated — the trap is closed by design.
in parameter = "let me read your struct without copying it, and I promise not to touch it."

Remember: every value-type copy is a full copy of every field — and mutability turns "a full copy" from a safety feature into a liability.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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 objectreadonly 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?

Show answer

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?

Show answer

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.