The difference is not "stack vs heap". It is what gets copied when you assign, pass, or capture a variable.
A value type variable holds the data itself. A reference type variable holds an address; the data lives elsewhere. Assignment always copies the variable — for a struct that means every field, for a class that means the address only.
struct Point { public int X; public int Y; }
class Box { public int X; }
var a = new Point { X = 1 };
var b = a; // full copy of the fields
b.X = 9; // a.X is still 1
var p = new Box { X = 1 };
var q = p; // copy of the reference
q.X = 9; // p.X is now 9A local struct usually sits on the stack, but a struct field inside a class lives on the heap with its owner, and a struct captured by a lambda is lifted into a compiler-generated class. "Value type" is about copy semantics; storage is an implementation detail the runtime picks.
Assigning a value type to object or to an interface allocates a heap box and copies the value into it. Unboxing copies it back out. In hot paths this is the quiet allocation that shows up as GC pressure — usually from a non-generic collection, a params object[], or an interface constraint the JIT cannot devirtualize.
object o = 42; // box: heap allocation + copy
int back = (int)o; // unbox: copy out
// generic constraint keeps it unboxed
static bool Eq<T>(T x, T y) where T : IEquatable<T> => x.Equals(y);Prefer a struct when the thing is small (roughly 16 bytes or less), immutable, and short-lived — an id, a money amount, a coordinate. Prefer a class for anything with identity or mutable state. A large mutable struct passed around by value is the worst of both: copies everywhere and surprising writes that go nowhere.
readonly struct + in parameters remove the defensive copies the compiler otherwise inserts. Measure before reaching for them.