Introduction #
C# gives you two ways to define a data type: struct and class. Both can hold fields and methods, both can be used almost interchangeably in a lot of beginner code, and that’s exactly why the choice gets made carelessly. The difference isn’t cosmetic. It changes how the value is stored, how it’s passed around, and how expensive it is to use at scale.
Best Practices #
- Default to structs for small, short-lived data. Coordinates, IDs, small config bundles.
- Default to classes when identity matters. If two references being “the same thing” is part of the logic, that’s reference semantics, that’s a class.
- Don’t mutate a struct through a getter. Structs returned by value from a property give you a copy. Mutating that copy silently does nothing to the original, a classic C# footgun.
Value semantics vs. reference semantics #
A struct is a value type. Assigning it, passing it to a method, or returning it copies the entire value. A class is a reference type. Assigning it copies a reference, and every copy points at the same underlying object.
public struct Vector2Int
{
public int X;
public int Y;
}
public class Inventory
{
public List<string> Items = new List<string>();
}
Vector2Int a = new Vector2Int { X = 1, Y = 1 };
Vector2Int b = a;
b.X = 99; // a.X is still 1, b is a separate copy
Inventory invA = new Inventory();
Inventory invB = invA;
invB.Items.Add("Sword"); // invA.Items now contains "Sword" too, same object
Neither behaviour is a bug. They’re different tools. A position, a color, a small immutable bundle of numbers: copying is cheap and the “everyone gets their own” semantics are usually what you actually want. A player’s inventory, a shared game-state object, anything with real identity: you want every reference pointing at the same thing.
Type escalation: start low, justify every step up #
This is Heathen’s own working rule for Unity code, and it applies just as well to any C# codebase: start at the bottom of this ladder, and only move up a step when you have a real, specific reason to.
- Start with a
struct. - Only move to a
classif you need reference semantics or inheritance. - Only move to a
UnityEngine.Object-derived type if you need Unity’s object identity/lifetime. - Only move to a
ScriptableObjectif you need asset-backed serialisation/inspector authoring. - Only reach for a
MonoBehaviourif you genuinely need scene attachment or per-frame Unity lifecycle callbacks.
Each step up the ladder needs a real justification, not habit. A data bag that’s being made a MonoBehaviour “just in case” is usually a struct that picked up unnecessary overhead and Unity object-lifetime baggage along the way.
Performance implications #
- Allocation: structs used locally or as fields typically live inline, with no separate heap allocation or garbage collector involvement. Classes are heap-allocated and add GC pressure, which matters at scale (thousands of entities, per-frame allocation in hot paths).
- Copy cost: a large struct copied on every method call can cost more than a reference copy would. Keep structs small, or pass them with
in/refwhen the copy cost is real and measured. - Cache locality: arrays of structs pack contiguously in memory, which is exactly why data-oriented systems (see: DataLens’s own column-major, bit-packed approach) lean on structs and flat arrays instead of arrays of class references scattered across the heap.