Interfaces, Inheritance, and Composition — How C# Types Share Behaviour

Why This Matters When AI Can Just Write the Code

An AI assistant can generate an interface, a base class, or a composed object graph in seconds — so why does understanding the underlying design tradeoffs still matter? Because generating code that compiles is not the same as generating code that will still be healthy to work with a year from now, and the choices this post covers — interface vs. base class, inheritance vs. composition — are exactly the kind of decision an AI tool cannot reliably make correctly on your behalf, because making it well requires knowing things about your system’s future that simply aren’t in the prompt.

Consider a concrete case: an AI assistant asked to “add logging to these employee classes” will very often reach for a shared base class, because it’s the shortest path to something that compiles, and inheritance-heavy examples dominate a huge amount of the training material such tools learn from. It has no way of knowing that your Employee hierarchy is about to need three more cross-cutting concerns — auditing, permissions, notifications — that don’t nest cleanly into a single chain of base classes. That’s a fact about your project’s trajectory, not something derivable from the code that exists today. The fragile base class problem this post walks through in detail doesn’t show up in the first version of the code at all — it shows up eighteen months later, when someone (quite possibly an AI assistant, asked to “optimise this base class method”) makes a locally reasonable change that silently breaks three derived classes it never saw and was never told about. Preventing that requires a human who understands why tight coupling to a base class’s implementation is risky, at the moment the original design decision gets made — not after the bug report arrives.

There’s also a much more immediate, mechanical trap this post covers: boxing. An AI tool asked to “store some shapes in a list and print their areas” might hand you a List<IShape> full of struct-based shapes without ever mentioning that every single element just got silently boxed onto the heap, or that mutating one of them through the interface reference won’t actually touch your original value. The code runs. It even produces correct output in a small demo. The performance cost and the mutation bug only show up later, under real load, or when someone tries to update a value and can’t figure out why the change isn’t sticking — and at that point you need to already understand what an interface-typed variable actually is under the hood to have any chance of diagnosing it.

The through line across all of this: AI tools are excellent at producing code that satisfies the request sitting in front of them right now. They are not a substitute for the judgement that decides what to ask for — whether a relationship is genuinely “is-a” and stable, whether a value is about to be boxed, whether a shared base class is going to become a maintenance liability. That judgement is something you bring to the tool; it isn’t something the tool can hand back to you.

Why This Post Exists

This post has two connected goals. The first is to properly introduce interfaces as a concept in their own right — not just “a thing you type after a colon,” but a fundamentally different kind of type from class, struct, record, and record struct, with its own rules and its own sharp edges (boxing, chief among them). The second goal builds directly on the first: once you understand what an interface is and how it differs from inheritance, you’re in a position to properly evaluate one of the oldest and most consequential design debates in object-oriented programming — when to reuse behaviour through inheritance, and when to reuse it through composition instead.

By the end of this post you should be able to explain, structurally, what an interface actually is and why it can be implemented identically by a reference type or a value type; predict exactly where boxing will silently occur when value types meet interfaces; explain the fragile base class problem in your own words, with a concrete example; build the same shared behaviour two different ways — via a base class and via composition — and compare the tradeoffs directly; and make a reasoned, justified choice between inheritance and composition for a specific design situation, rather than defaulting to either one out of habit.

What an Interface Actually Is, Structurally

Every type you might already be familiar with — class, struct, record, record struct — is something that holds data. When you create an instance of any of them, memory gets set aside (on the heap or inline, depending on the kind) to store actual values: an int X, a string Name, whatever fields or properties the type declares.

An interface is fundamentally different: it holds no data of its own, and, historically, contained no implementation at all. An interface doesn’t describe what a thing is made of — it describes what a thing can do. It’s a contract, a promise, a list of members that any implementing type agrees to provide:

interface IShape
{
double Area();
double Perimeter();
}

IShape has no fields, and — in its classic form — no method bodies. You cannot write new IShape(); there is nothing to construct, because an interface isn’t a blueprint for an object, it’s a checklist a type must satisfy. This is the single most important thing to internalise before anything else in this post: a class/struct/record/record struct answers the question “what data does this hold and how is it stored?” An interface answers a completely different question: “what operations can I guarantee this type supports?”

Because of that difference in purpose, an interface can’t stand alone the way the other four kinds can. IShape by itself doesn’t correspond to any actual object in memory — you always need some concrete type (a class, struct, record, or record struct) that implements IShape and supplies the actual fields and method bodies before you have something you can construct and use:

class Circle : IShape
{
public double Radius;
public double Area() => Math.PI * Radius * Radius;
public double Perimeter() => 2 * Math.PI * Radius;
}

IShape shape = new Circle { Radius = 3 }; // shape is typed as IShape, but the actual object is a Circle

Notice the variable shape is declared with type IShape, but there’s no such thing as “a bare IShape object” sitting in memory. What’s actually there is a Circle, and shape is simply a way of looking at that Circle through a narrower lens — one that only exposes the members IShape promises (Area() and Perimeter()), hiding everything else Circle might also have (like the public Radius field itself, which isn’t accessible through an IShape-typed reference without casting back to Circle).

An Interface Is Orthogonal to class/struct/record/record struct — Not a Fifth Option

This is the point students most often get subtly wrong: it’s tempting to mentally file “interface” as a fifth item in the same list as class/struct/record/record struct, as if you’re choosing between five options. That’s not the right mental model. Every one of those four type kinds can implement any number of interfaces, completely independently of which kind it is. The choice of “class vs struct vs record vs record struct” and the choice of “which interfaces does this type implement” are two entirely separate decisions that don’t constrain each other.

To make this concrete, here’s the exact same interface implemented by all four kinds:

interface IShape
{
    double Area();
}

class CircleClass : IShape
{
    public double Radius;
    public double Area() => Math.PI * Radius * Radius;
}

struct CircleStruct : IShape
{
    public double Radius;
    public double Area() => Math.PI * Radius * Radius;
}

record CircleRecord(double Radius) : IShape
{
    public double Area() => Math.PI * Radius * Radius;
}

record struct CircleRecordStruct(double Radius) : IShape
{
    public double Area() => Math.PI * Radius * Radius;
}

All four of these compile without complaint, and all four satisfy IShape identically as far as anything consuming the interface is concerned:

void PrintArea(IShape shape) => Console.WriteLine(shape.Area());

PrintArea(new CircleClass { Radius = 2 });        // works
PrintArea(new CircleStruct { Radius = 2 });        // works
PrintArea(new CircleRecord(2));                    // works
PrintArea(new CircleRecordStruct(2));               // works

PrintArea doesn’t know or care whether it was handed a reference type or a value type, a plain type or a record — it only knows it received something that has an Area() method, because that’s all IShape promises. This is the entire point of an interface: it lets you write code against a capability, without committing to a specific underlying representation.

You now have three genuinely independent axes describing any given type: reference vs. value, plain vs. record, and implements-this-interface vs. doesn’t. None of the three constrains the others. You could just as easily write a class that does not implement IShape, or a struct that implements three different interfaces at once, or a record that implements none — all of that is unrelated to whether the type is a class, struct, record, or record struct.

Interface Variables and Boxing

Assigning a value type to an interface-typed variable creates the exact same boxing situation as assigning it to object, and it’s worth walking through explicitly, because it’s a very easy trap to fall into without noticing.

struct CircleStruct : IShape
{
public double Radius;
public double Area() => Math.PI * Radius * Radius;
}

var s = new CircleStruct { Radius = 2 }; // lives on the stack, no allocation yet
IShape shape = s; // BOXING happens right here!

The moment you assign a value-type instance to a variable of interface type, the runtime has to box it — that is, copy the entire value onto the heap and hand you back a reference to that heap copy, because an interface-typed variable is, under the hood, a reference, and a value type has no “address” of its own to hand out. This happens completely silently; there’s no special syntax that flags it, no warning, nothing in the code that visually screams “allocation happening here.” The line IShape shape = s; looks just as innocuous as any other assignment, but it triggers a heap allocation that the equivalent line for a class-based IShape implementation never would.

This has a very concrete, easy-to-demonstrate consequence: boxing creates a copy, so mutating the boxed copy through the interface reference does not affect the original struct:

interface ICounter { void Increment(); }

struct Counter : ICounter
{
public int Value;
public void Increment() => Value++;
}

var c = new Counter { Value = 0 };
ICounter boxed = c; // boxing: a separate copy is made on the heap
boxed.Increment(); // mutates the BOXED COPY
Console.WriteLine(c.Value); // still 0 — the original struct never changed

This is a classic, very real source of confusing bugs: code that looks like it’s calling a mutating method on your object is actually calling it on a disposable heap copy that gets thrown away the moment the interface reference goes out of scope. class and record never have this problem, because they were reference types to begin with — assigning one to an interface-typed variable is just copying a reference to the same object that already existed, with no boxing and no hidden copy.

The practical lesson: if you’re storing value types (struct/record struct) in a collection typed by interface — a List<IShape> full of struct-based shapes, for instance — every single element got boxed on the way in, and you’re paying a heap allocation per element, exactly the cost you were probably trying to avoid by choosing a value type in the first place.

Multiple Interface Implementation vs. Single Base Class

Here’s a structural fact about interfaces that becomes the seed of the entire second half of this post, so it’s worth planting clearly now: a type can implement as many interfaces as you like, but a class or record can only inherit from one base class.

interface IFlyable { void Fly(); }
interface ISwimmable { void Swim(); }
interface IWalkable { void Walk(); }

class Duck : IFlyable, ISwimmable, IWalkable
{
public void Fly() => Console.WriteLine("Flying");
public void Swim() => Console.WriteLine("Swimming");
public void Walk() => Console.WriteLine("Walking");
}

Duck satisfies three completely independent contracts at once, with no conflict. Contrast this with inheritance, where C# only allows a single base class:

class Animal { }
// class Duck : Animal, SomeOtherBaseClass { } // ILLEGAL — cannot inherit from two classes

This asymmetry exists because inheritance from a base class brings along actual implementation and state — fields, method bodies, constructors — and the language (like most mainstream OOP languages) sidesteps the substantial complexity of “what happens when two base classes both define a field called X, or both implement a method the same way” by simply disallowing multiple base classes altogether. Interfaces sidestep this problem because, classically, they carry no state and no implementation to conflict — a type promising to satisfy IFlyable and a type promising to satisfy ISwimmable can’t possibly clash, because neither promise brings any actual code or data along with it.

This is exactly why interfaces are the tool of choice when a type needs to participate in several unrelated capabilities at once — a Duck being flyable, swimmable, and walkable are three orthogonal facts about it, and forcing that into a single inheritance chain (Duck : FlyingAnimal, but then where does swimming come from?) would quickly become awkward or impossible. Keep this example in mind — Part Two builds directly on this observation to explain why composing behaviour out of small, focused interfaces is often a better design than building deep inheritance trees.

Default Interface Members — Interfaces Aren’t Entirely Implementation-Free Anymore

Everything above described the classic, “pure contract” interface: no fields, no method bodies, only signatures. That was the whole story before C# 8, and it’s still the right mental model for the overwhelming majority of interfaces you’ll write. But since C# 8, interfaces have been allowed to provide a default implementation for a member, which a class doesn’t have to override unless it wants to:

interface IGreeter
{
string Name { get; }
void Greet() => Console.WriteLine($"Hello, {Name}!"); // default implementation
}

class Robot : IGreeter
{
public string Name => "R2D2";
// Robot doesn't implement Greet() at all — it inherits IGreeter's default body
}

IGreeter g = new Robot();
g.Greet(); // prints "Hello, R2D2!" using the interface's own default implementation

This is a genuinely useful escape hatch for library authors: it lets you add a new member to a widely-used interface after the fact, without breaking every existing type that already implements it, because any existing implementer that doesn’t define the new member simply falls back to the interface’s default body instead of failing to compile.

It’s important to be precise about what this does and doesn’t change, though. A default interface member is still only reachable through the interface-typed reference, not through the concrete type directly:

Robot r = new Robot();
// r.Greet(); // Compile error! Robot itself doesn't declare Greet() — only IGreeter does
IGreeter g = r;
g.Greet(); // Works — accessed through the interface

And an interface still cannot hold instance state — no fields — even with default members, so it remains fundamentally different from a base class, which can hold both fields and default behaviour. The takeaway isn’t “interfaces are basically abstract classes now” — they’re still not — but rather: don’t be surprised in real-world code when you see an interface member with a body, and know that it means “this is the fallback behaviour, which any implementer is free to override,” not “this interface secretly has state or a constructor.”

Two Different Ways to Reuse Code

Part One established two facts that this half of the post now builds directly on top of: struct and record struct cannot inherit from anything at all, while class and record can inherit from exactly one base type; and any of the four type kinds can implement as many interfaces as it likes, with no such “only one” restriction. Those two facts, side by side, raise an obvious design question: when you want two or more types to share behaviour, when should you reach for inheritance, and when should you reach for composition instead?

This isn’t just a C#-specific syntax question — it’s one of the oldest and most consequential design debates in object-oriented programming, usually summarised as the principle “favour composition over inheritance.” Suppose you’re building a small set of employee types, and every employee needs the ability to log a message somewhere. There are two structurally different ways to give every employee type that ability.

Inheritance says: create a common base class that contains the shared behaviour, and have every type that needs it inherit from that base class. This is often described as an “is-a” relationship — a Manager is a Employee, so it makes sense for Manager to inherit from Employee and receive everything Employee already knows how to do.

class Employee
{
public string Name;
public void Log(string message) => Console.WriteLine($"[{Name}] {message}");
}

class Manager : Employee
{
public void ApproveTimeOff(Employee e) => Log($"Approved time off for {e.Name}");
}

Manager gets Log for free, simply by inheriting from Employee. This works, and for a simple, genuinely hierarchical relationship like this one, it’s a completely reasonable choice.

Composition says something different: instead of inheriting the behaviour, hold a reference to an object that provides it, and delegate to that object when you need the behaviour. This is often described as a “has-a” relationship — an Employee has a logger, rather than is a logger.

interface ILogger
{
void Log(string message);
}

class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}

class Employee
{
private readonly ILogger _logger;
public string Name;

public Employee(ILogger logger) => _logger = logger;

public void LogActivity(string message) => _logger.Log($"[{Name}] {message}");
}

Here, Employee doesn’t inherit logging behaviour from anywhere — it holds an ILogger and delegates to it. Nothing about Employee’s type hierarchy needs to change to support logging; it just needs a reference to something that knows how to log.

Both examples achieve the same visible result (an employee that can log a message), but they achieve it through fundamentally different mechanisms, and those mechanisms have very different consequences as the codebase grows — which is the subject of the rest of this post.

Inheritance’s Real Cost: Tight Coupling and the Fragile Base Class Problem

Inheritance isn’t wrong — it’s genuinely the right tool in some situations, which we’ll cover later— but it comes with a cost that’s easy to underestimate the first time you use it, and expensive to discover later: a derived class is tightly coupled to its base class’s implementation, not just its public contract. When you inherit from a class, you don’t just gain access to its public members — you become dependent on how those members are implemented internally, in ways that aren’t always obvious from reading the derived class alone.

This is widely known as the fragile base class problem: a seemingly safe, reasonable change to a base class can silently break derived classes that depend on it, even though nothing about the base class’s public interface changed. Here’s a concrete demonstration:

class Collection
{
private List<int> _items = new();

public virtual void Add(int item)
{
_items.Add(item);
Console.WriteLine("Item added");
}

public void AddRange(IEnumerable<int> items)
{
foreach (var item in items)
Add(item); // calls the virtual Add — including any override!
}
}

class LoggingCollection : Collection
{
public int AddCount = 0;

public override void Add(int item)
{
base.Add(item);
AddCount++;
}
}

At first glance, LoggingCollection looks correct: every call to Add increments AddCount, so AddCount should always equal the number of items added. But watch what happens with AddRange:

var c = new LoggingCollection();
c.AddRange(new[] { 1, 2, 3 });
Console.WriteLine(c.AddCount); // 3 — correct, because AddRange calls the overridden Add for each item

This particular example happens to work, but only because Collection.AddRange was deliberately written to call the virtual Add method, so overrides get picked up. That’s precisely the danger: LoggingCollection’s correctness silently depends on an internal implementation detail of Collection — specifically, that AddRange routes through Add rather than, say, appending directly to a private list for efficiency. If a future maintainer of Collection “optimises” AddRange like this:

public void AddRange(IEnumerable<int> items)
{
_items.AddRange(items); // "optimisation": skip the virtual call overhead
Console.WriteLine($"{items.Count()} items added");
}

LoggingCollection.AddCount silently stops being accurate, and nothing about LoggingCollection’s own code changed at all. The bug was introduced entirely inside the base class, in a method LoggingCollection never even overrode. This is the fragile base class problem in miniature: the base class’s author has to think about every possible derived class’s assumptions before making almost any internal change, forever — because derived classes are coupled not just to the base class’s public signatures, but to its internal call patterns, which are usually invisible from outside.

The deeper this hierarchy grows — EmployeeSalariedEmployeeManagerSeniorManager, each layer adding assumptions about the layers below — the harder it becomes to change anything near the top without a ripple of subtle breakage further down, and the harder it becomes for someone reading SeniorManager in isolation to know which behaviours actually come from where. Composition sidesteps this problem structurally: when Employee merely holds an ILogger, nothing about ILogger’s internal implementation can leak into Employee’s correctness in this way, because the only thing Employee depends on is the interface’s public contract.

Composition in Practice: Building Behaviour Out of Small, Focused Interfaces

The Duck example from Part One — implementing IFlyable, ISwimmable, and IWalkable all at once — was already an example of composition-friendly thinking, just without a supporting object behind each interface yet. Let’s complete that picture. Rather than a Duck inheriting flying/swimming/walking behaviour from some deep, awkward hierarchy, it can instead hold small objects that each know how to do one thing, and delegate to them:

interface IMovementStrategy { void Move(); }

class FlyingMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Flapping wings and flying");
}

class SwimmingMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Paddling through water");
}

class Duck
{
private readonly IMovementStrategy _airMovement;
private readonly IMovementStrategy _waterMovement;

public Duck(IMovementStrategy airMovement, IMovementStrategy waterMovement)
{
_airMovement = airMovement;
_waterMovement = waterMovement;
}

public void Fly() => _airMovement.Move();
public void Swim() => _waterMovement.Move();
}

var duck = new Duck(new FlyingMovement(), new SwimmingMovement());
duck.Fly(); // "Flapping wings and flying"
duck.Swim(); // "Paddling through water"

Notice what this buys you that inheritance couldn’t easily offer: the specific way a Duck flies is now a swappable, independently testable piece, completely decoupled from what a Duck fundamentally is. If you later need a RoboDuck that swims the same way but “flies” via a jetpack, you don’t need to redesign an inheritance hierarchy at all — you just construct it with a different IMovementStrategy:

class JetpackMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Rocketing through the air");
}

var roboDuck = new Duck(new JetpackMovement(), new SwimmingMovement());
roboDuck.Fly(); // "Rocketing through the air" — no change to the Duck class itself

This is precisely why composition is often described as more flexible than inheritance: behaviour becomes something you plug in through a constructor (or property), rather than something baked permanently into a type’s position in a fixed hierarchy at compile time. It also makes unit testing considerably easier — in a test, you can hand Duck a fake IMovementStrategy that does nothing but record that it was called, without needing to construct any of the real flying/swimming logic at all, something that’s often awkward to do cleanly when behaviour comes from an inherited base class instead.

Composition and Value Types: Not a Workaround, but the Only Option

Above, we established that struct and record struct cannot inherit from anything at all — this isn’t a missing feature the language forgot to add, it’s a direct, deliberate consequence of what a value type is. Because of this, composition isn’t an optional alternative for value types the way it is for classes — it’s the only mechanism value types have for sharing or reusing behaviour beyond what interfaces alone provide.

readonly record struct Point(double X, double Y)
{
public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}

readonly record struct Circle(Point Center, double Radius)
{
public double Area() => Math.PI * Radius * Radius;
public bool Contains(Point p) => Center.DistanceTo(p) <= Radius;
}

Circle doesn’t — and structurally can’t — inherit from Point to get distance calculations. Instead, it composes a Point as one of its own fields, and reuses Point’s DistanceTo method by calling it on that held instance. This is a completely natural, idiomatic way to build up richer value types out of smaller ones, and it’s worth explicitly noticing that this is exactly the same composition principle from the Duck example — just applied to small, immutable value objects instead of behaviour-swapping strategy classes. Small, focused, composable value objects like Point, Money, and Address are exactly the kind of thing record/record struct are designed to make easy to build and combine.

So When Does Inheritance Actually Make Sense?

None of this means inheritance is a mistake to avoid entirely — it means inheritance should be a deliberate choice made when its specific strengths genuinely apply, rather than a reflexive first instinct. Inheritance tends to be the right tool when:

  • The relationship is genuinely “is-a,” and is unlikely to need to change at runtime. A Circle really is a kind of Shape in a way that’s true for the entire lifetime of the object — it doesn’t stop being a circle and become a rectangle later. Contrast this with “a Duck can currently fly using wings, but that specific mechanism might reasonably need to be swapped” — that’s a “has-a, and might change” relationship, which favours composition.
  • You want to take advantage of polymorphism through a shared base type, especially when you have a closed, well-understood set of variants and want the compiler to help you handle them exhaustively — for example, an abstract record Shape with sealed record Circle/Rectangle subclasses, paired with a switch expression that handles each case.
  • The shared behaviour is small, stable, and unlikely to change underneath derived types. The fragile base class problem gets worse the more a base class’s internals change over its lifetime; if a base class is simple and essentially “done,” the risk that inheritance’s tight coupling will bite you is much lower.
  • You’re modelling a genuine hierarchy with more than one level of specialisation that the domain itself supports — for example, a UI framework’s ControlButtonBaseButton hierarchy reflects real, stable, structural relationships in how those types behave, not just convenient code reuse.

The practical decision process, then, isn’t “composition good, inheritance bad” — it’s closer to: default to composition and interfaces for sharing behaviour, especially across value types or unrelated capabilities, and reach for inheritance specifically when you have a genuine, stable “is-a” relationship where polymorphism is the actual goal, not just a convenient way to avoid retyping a method.

Summary

An interface is not a fifth alternative to class/struct/record/record struct — it’s an orthogonal contract that any of the four can promise to fulfil, completely independent of whether that type is a reference type or a value type, or a plain type or a record. All four type kinds can implement the same interface identically from the caller’s perspective. The place this orthogonality has a real, measurable cost is boxing: assigning a struct/record struct to an interface-typed variable silently allocates a heap copy, and mutating through that boxed copy never affects the original value — a class/record never has this problem, because it was already a reference type. Interfaces also don’t share inheritance’s “only one” restriction — a type can implement as many interfaces as it needs, which is precisely what makes them the natural tool for composing several independent capabilities onto one type. And since C# 8, interfaces can carry default implementations for their members, which softens but doesn’t erase the core distinction: an interface still holds no state of its own, and remains fundamentally a promise about behaviour, not a container for data.

Building on that: inheritance and composition are two different mechanisms for sharing behaviour across types, and they carry very different long-term costs. Inheritance expresses an “is-a” relationship and gives you polymorphism through a shared base type, but it comes with tight coupling to the base class’s implementation, not just its public contract — the fragile base class problem means a seemingly safe change deep inside a base class can silently break derived classes that never touched the changed code. Composition expresses a “has-a” relationship: instead of inheriting behaviour, a type holds a reference to an object (usually through an interface) and delegates to it, which keeps types decoupled from each other’s internals and makes behaviour swappable at runtime rather than fixed at compile time. This isn’t a purely stylistic preference for class/record — for struct/record struct, which structurally cannot inherit at all, composition is the only mechanism available for building richer value types out of smaller ones. The practical guidance is to default to composition and interfaces, and reach for inheritance specifically when you have a genuine, stable “is-a” relationship where polymorphism through a shared base type is the actual goal — not simply a shortcut to avoid writing a method twice.

Null, not null, forgiving null (or maybe not)

Why This Matters When AI Can Just Write the Code

An AI assistant can generate null checks in seconds — so why does understanding null handling matter if you can just ask for it? Because null-related bugs are almost never caught by generating code; they’re caught by reading code and recognising a gap in its reasoning, and that’s a skill no amount of code generation substitutes for.

Here’s a concrete version of the problem: an AI tool asked to fix a compiler warning about a possibly-null reference will very often reach for the null-forgiving operator (!) — it makes the warning disappear, which looks like success. But ! doesn’t add a safety check; it removes one, telling the compiler “trust me, this is never null” without verifying that’s actually true. If the assumption is wrong, the code now fails at runtime with a NullReferenceException, in a place that used to at least warn you at compile time. Whether that ! was a reasonable judgement call or a bug waiting to happen depends entirely on context the tool doesn’t reliably have — and if you don’t understand what ! actually does, you have no way to tell the difference between “the AI just fixed a warning” and “the AI just hid a real bug” by reading the diff.

There’s a second reason this topic specifically rewards genuine understanding: null handling sits at the intersection of the type system and runtime behaviour in a way that’s easy to get subtly wrong even when every individual line looks reasonable. Knowing the difference between a compile-time-only annotation and an actual runtime guarantee — which this post covers in detail — is exactly the kind of judgement that determines whether you trust a piece of generated code or double-check it. That judgement is yours to develop; no tool can hand it to you pre-installed.

Every C# codebase you’ll ever work in has to deal with the possibility that a value is missing, not yet computed, not yet loaded, or intentionally absent. null is the language’s way of representing “nothing is here” — and it is, by a wide margin, one of the most common sources of runtime crashes in real software. This post covers what null actually is at the language and memory level, every mainstream tool C# gives you for checking and guarding against it, the crucial and easily-confused distinction between nullable value types and nullable reference types, the attributes that let library authors describe null behaviour precisely, and the null-forgiving operator — a tool that’s easy to misuse precisely because it looks like a safety feature.

By the end of this post you should be able to explain, precisely, what null represents in memory for a reference type and why the same idea doesn’t apply to a value type; correctly predict, for any given line of code, whether a nullability rule is enforced at compile time, at runtime, or not enforced at all; choose the right null-checking idiom for a given situation and justify the choice; read and apply the nullability attributes ([NotNullWhen], [MaybeNull], and similar) that describe how a method’s nullability behaves; and recognise when the null-forgiving operator is a defensible judgement call versus a bug waiting to happen.

What Null Actually Is, at the Memory Level

To understand null precisely, it helps to think about what a variable actually contains for each kind of type.

For a reference type (a class, for instance), a variable never directly contains the object’s data. It contains a reference — conceptually, an address — pointing to where the actual object lives on the heap. null is simply the special, reserved value meaning “this reference doesn’t point anywhere at all.” No heap object is required to exist for a variable to hold null; the variable is, in a very literal sense, empty.

string name = null;   // name holds no address at all — there is no string object to find
Console.WriteLine(name.Length); // throws NullReferenceException

name.Length fails here because evaluating it requires the runtime to follow the reference stored in name to find an actual string object and read its Length property off of it. But name isn’t pointing at anything, so there’s no object to follow the reference to, and the runtime throws NullReferenceException rather than silently doing nothing or returning a default value. This single mechanism — attempting to dereference an empty reference — is the direct cause of the most common runtime exception in C# (and in every other mainstream language that adopted the same idea from reference-type null).

For a value type (like int or a struct), the story is completely different: the variable is the data, stored directly and inline, with no indirection at all. An int variable is quite literally four bytes holding a number — there’s no separate “int object” living elsewhere that the variable points to. Because there’s no reference involved, there’s nothing that could be “empty” — the variable always holds actual bits representing some value.

int x = null; // compile error CS0037 — there is no concept of "no address" for a value type

This is why the compiler rejects this outright, at compile time, rather than allowing it and failing later — the very idea of null doesn’t apply to how a plain value type is represented in memory. This distinction is the foundation for everything else in this post: Nullable<T> and nullable reference types are two entirely different mechanisms, built to solve the same conceptual problem (“how do I represent ‘no value’ for this type?”) for two categories of type that are represented in memory in fundamentally different ways.

It’s worth a brief historical note here, because it explains why the language ended up with two different mechanisms instead of one unified one: the inventor of the null reference, Tony Hoare, has publicly called it his “billion-dollar mistake” — a feature that seemed convenient to add to a type system in 1965, but which he estimates has since caused decades of subsequent bugs, crashes, and security vulnerabilities across virtually every mainstream language that copied the idea. Nullable<T> (added in C# 2.0) and nullable reference types (added in C# 8.0, over a decade later) represent two separate, successive attempts to make that original mistake less costly — one for value types, one for reference types — without breaking the enormous amount of existing code built on the assumption that any reference could always be null.

Nullable Value Types: Nullable<T> in Full

Since a plain value type can never be null, C# provides a real, concrete wrapper type — Nullable<T> — that adds an explicit “is a value actually present or not” flag alongside the underlying data. You’ll almost always see it written using the shorthand T?:

int? age = null;          // shorthand for Nullable<int> age = null;
Nullable<int> age2 = null; // exactly equivalent, spelled out in full

Nullable<T> is itself a struct, meaning int? remains, fundamentally, a value type — not a disguised reference type. Its actual definition is close to this simplified version:

public struct Nullable<T> where T : struct
{
public bool HasValue { get; }
public T Value { get; } // throws InvalidOperationException if HasValue is false
public T GetValueOrDefault();
public T GetValueOrDefault(T defaultValue);
}

So when you write int? age = null;, nothing about “emptiness” or “missing references” is happening — you’re creating an ordinary struct value whose HasValue field happens to be false. The variable is never “pointing at nothing”; it directly contains a small struct value that represents absence as one of its normal, well-defined states, the same way a bool can represent true or false.

int? age = null;
Console.WriteLine(age.HasValue);           // False
// Console.WriteLine(age.Value);           // throws InvalidOperationException — no value to return
Console.WriteLine(age.GetValueOrDefault()); // 0 — the default value of int, used safely instead of throwing

age = 30;
Console.WriteLine(age.HasValue); // True
Console.WriteLine(age.Value);    // 30

GetValueOrDefault() is often safer to reach for than .Value directly, precisely because it can never throw — it simply falls back to default(T) (or an explicit fallback you supply) when there’s no value present, rather than requiring you to check HasValue first every single time.

Lifted operators

C# does something convenient behind the scenes called “operator lifting”: arithmetic and comparison operators automatically work on Nullable<T> values, propagating null through the calculation, without you having to unwrap .Value manually:

int? a = 5;
int? b = null;

int? sum = a + b; // null — if either operand is null, the result is null
Console.WriteLine(sum.HasValue); // False

bool isGreater = a > b; // False — comparisons involving a null operand are always false, never throw

Notice the last line carefully: a > b doesn’t throw and doesn’t propagate null the way arithmetic does — it evaluates to false, because a comparison against “no value” is defined as never being true. This is a common source of subtle bugs: a > b being false does not mean a <= b is true when either side might be null — both comparisons can independently be false if one side is null, which surprises people expecting comparisons to be perfect opposites of each other the way they are for non-nullable numbers.

Boxing behaviour — a special case worth knowing

Normally, boxing a value type wraps it in an object on the heap, as covered in earlier value-type discussions. Nullable<T> has a special, deliberately designed exception to ordinary boxing rules: boxing a null-valued Nullable<T> produces an actual null reference, not a boxed struct with HasValue == false sitting on the heap.

int? x = null;
object boxed = x;
Console.WriteLine(boxed == null); // True — boxing a null Nullable<T> yields an actual null reference, not a boxed struct

This special-cased behaviour exists specifically so that boxed nullable value types behave the way programmers intuitively expect when compared against null or checked with is null — without it, a great deal of ordinary-looking code checking someObject == null would behave surprisingly for nullable value types passed around as object.

Nullable Reference Types (NRT): A Compile-Time Annotation, Not a Runtime Guarantee

Historically, every reference type in C# could always be null — there was no way to say “this particular string variable should never be null” and have the compiler enforce it. Since C# 8, when a project enables the nullable reference types feature (also called “the nullable context”), you can annotate reference type usages with ? to declare intent:

string name = "Alice";   // intended to always have a value — a "non-nullable" reference type
string? nickname = null; // intended to possibly have no value — a "nullable" reference type

Here is the single most important fact in this entire post, and the one students most reliably get wrong the first time: this ? is purely a compile-time annotation that the compiler uses to generate warnings. It changes absolutely nothing about what happens at runtime. Unlike Nullable<T>, which is a real wrapper struct that genuinely changes the shape of the underlying data, nullable reference type annotations are erased completely by the time your code runs. A string? and a string are the exact same type — System.String — at runtime, compiled to identical IL, with identical behaviour. The only difference between them exists in the compiler’s static analysis while you’re writing the code, not in anything the CLR (Common Language Runtime) knows or checks while your program is actually running.

You can prove this to yourself directly:

string name = null; // triggers compiler WARNING CS8600 — but this still compiles and runs!
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime, exactly as before NRT existed

Nothing stops this from compiling. The nullable reference types feature is, by design, a warning system layered on top of the language — not an enforced runtime guarantee the way Nullable<T> is. This is a deliberate, pragmatic tradeoff: C# has decades of existing code where every reference type could be null, and retroactively making non-nullable the enforced runtime default would have broken an enormous amount of that code the moment anyone upgraded their compiler. Instead, the compiler performs static flow analysis — tracking, as precisely as it can, which variables might be null at each point in your code — and surfaces a warning wherever it believes you might be about to dereference something that could be null, without physically preventing you from doing it anyway.

The nullable context: enabling and controlling NRT

Whether nullable reference type checking is even active is itself controlled, either project-wide (typically via <Nullable>enable</Nullable> in your .csproj file) or file-by-file, or even line-by-line, with a compiler directive:

#nullable enable
string? maybeNull = null; // annotations are meaningful here — warnings apply

#nullable disable
string alsoNull = null;   // in a disabled context, no warning at all — this is the pre-C#-8 world

#nullable restore          // returns to whatever the surrounding project-level setting was

Code written before nullable reference types existed, or code in a project that hasn’t opted in, is said to be in an “oblivious” context — the compiler doesn’t apply nullable warnings to it at all, and every reference type behaves exactly as it always did (implicitly nullable, no annotations, no warnings). This matters practically: if you’re calling into an older library that hasn’t been annotated for nullability, the compiler generally can’t warn you about null risks flowing out of that library’s methods, even in your own fully nullable-enabled code, because the library itself never declared its intentions.

Generic type parameters and NRT

Nullability annotations interact with generics in a way that’s worth flagging explicitly, since it trips people up:

class Box<T>
{
public T Value; // is this nullable or not? It depends entirely on what T ends up being!
}

Box<string> b1 = new Box<string> { Value = null };
Box<int> b2 = new Box<int> { Value = null };

The compiler has to reason about nullability generically here, because T might end up being a reference type or a value type depending on how Box<T> gets used — this is a genuinely more advanced corner of the feature, and library authors writing generic code often need extra annotations (like T? combined with constraints, or the [MaybeNull]/[AllowNull] attributes covered later) to precisely describe nullability that depends on the type parameter.

Advisory, not enforced

This all has a very practical consequence worth internalising: nullable reference type warnings are advisory, not enforced, by default. If your team ignores compiler warnings, or your build pipeline doesn’t treat warnings as errors, string? provides essentially no actual protection at all — it becomes documentation of intent that nothing is actually forcing anyone to respect. Many professional teams configure their build to treat nullable-related warnings as build-breaking errors (<WarningsAsErrors>Nullable</WarningsAsErrors> or similar) specifically to close this gap and make the annotations behave more like a genuine guarantee — which is worth knowing exists as an option even though it isn’t the default.

Checking for Null

C# provides several distinct ways to check whether something is null, and they aren’t fully interchangeable — the differences matter, sometimes a great deal.

== null vs. is null

if (name == null) { ... }
if (name is null) { ... }

Both usually produce the same result, but is null is the generally preferred idiom, for a specific and important reason: == is an operator, and operators can be overloaded by the type being compared. If the type of name overloads == with custom logic — records do exactly this automatically, since they generate their own == for value equality — then name == null invokes that custom operator rather than a guaranteed, unambiguous null check. A poorly written custom == overload could, in principle, behave unexpectedly when one side is null (for example, throwing instead of returning false, if the override doesn’t defensively handle a null argument). is null, by contrast, is a pattern match evaluated directly by the compiler using the runtime’s built-in notion of “this reference is empty” — it cannot be overridden, intercepted, or fooled by any type’s custom operator, so it always performs a true, unambiguous null check no matter what type you’re checking, which is exactly the guarantee you want from a null check.

The null-conditional operator: ?.

Rather than writing a nested chain of null checks by hand, ?. lets you safely navigate through a chain of references that might be null at any step, automatically short-circuiting to null the moment it encounters one:

// Without null-conditional:
string city = null;
if (customer != null && customer.Address != null)
city = customer.Address.City;

// With null-conditional — equivalent, far more concise:
string city = customer?.Address?.City;

If customer is null, the entire expression short-circuits immediately and evaluates to null — it never even attempts to access .Address, avoiding the exception a plain customer.Address.City would throw. You can also use it to conditionally invoke a method or event, calling it only if the reference isn’t null and otherwise doing nothing at all:

onCompleted?.Invoke(); // calls Invoke() only if onCompleted isn't null; silently does nothing otherwise

?. also combines with indexers and method calls in a chain, short-circuiting the moment any link is null:

int? firstItemLength = list?[0]?.Length; // safely handles list being null, or list[0] being null

Null-coalescing operators: ?? and ??=

?? lets you supply a fallback value to use when the left-hand side turns out to be null, and it chains naturally with ?.:\

string city = customer?.Address?.City ?? "Unknown";
// If the whole chain above evaluates to null at any point, "Unknown" is used instead

??= is a compound assignment version: it assigns the right-hand side only if the variable currently holds null, leaving it completely unchanged otherwise — useful for lazy initialisation and simple caching patterns:

string? cachedResult = null;
cachedResult ??= ComputeExpensiveResult(); // only computes and assigns if cachedResult was null
cachedResult ??= ComputeExpensiveResult(); // this second call never even executes ComputeExpensiveResult again

Pattern matching against null

Modern C# lets you use is/is not directly as readable null checks, and switch expressions can match null explicitly as one of several patterns, putting the null case on equal footing with every other case being matched rather than treating it as a special pre-check:

if (name is not null)
Console.WriteLine(name.Length);

string Describe(object? value) => value switch
{
null => "nothing here",
int n when n < 0 => $"a negative int: {n}",
int n => $"an int: {n}",
string s => $"a string: {s}",
_ => "something else"
};

The Try-pattern as an alternative to nullable returns

A very common and idiomatic way to sidestep null-checking altogether for lookups that might fail is the “Try” pattern, seen throughout the .NET standard library (Dictionary<TKey,TValue>.TryGetValue, int.TryParse, and many others):

Dictionary<string, int> ages = new() { ["Alice"] = 30 };

if (ages.TryGetValue("Bob", out int age))
Console.WriteLine(age);
else
Console.WriteLine("Not found");

Rather than returning a nullable value type and forcing every caller to check for null (or, worse, returning a sentinel value like -1 that’s easy to forget to check for), the method returns a bool indicating success and delivers the actual value through an out parameter only when it succeeded. This is often considered a cleaner API design than nullable returns for exactly this kind of “might not find anything” scenario, because the success/failure check and the value retrieval happen in a single, hard-to-misuse expression — you cannot accidentally use age without having gone through the success check first, since it isn’t definitely assigned otherwise.

The Null-Forgiving Operator: !

The null-forgiving operator is a single exclamation mark placed after an expression, and it exists specifically to interact with the compile-time warning system:

string? maybeName = GetNameOrNull();
string name = maybeName!; // "trust me, compiler — this won't actually be null"

Here is the fact students most often assume incorrectly, so it’s worth stating as plainly as possible: the ! operator does absolutely nothing at runtime. It performs no check, adds no safety, and throws no exception if the value genuinely turns out to be null. Its only effect is telling the compiler’s nullable-warning analysis “stop warning me about this specific expression — I know something you don’t.” If you’re wrong, and the value actually is null, the code compiles cleanly with no warning at all, and then throws a NullReferenceException at runtime exactly as it would have without the ! — except now there was no compiler warning to have caught the mistake ahead of time, which arguably makes the situation worse than not using NRT at all, since it creates false confidence.

string? maybeName = null;
string name = maybeName!; // compiles with zero warnings
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime — the ! changed nothing here

This makes ! a genuinely double-edged tool, and it’s worth being explicit about when each side applies.

It’s a legitimate, reasonable choice when you have information the compiler’s flow analysis genuinely can’t see. A common example is right after a manual null check performed inside a separate helper method, which the compiler generally can’t trace through automatically:

void EnsureNotNull(string? value)
{
if (value is null) throw new ArgumentException();
}

string? input = GetInput();
EnsureNotNull(input);
string result = input!; // input can't actually be null here, but the compiler can't see that through the helper call

(Later we’ll cover [NotNull] and related attributes, which let you tell the compiler about exactly this kind of pattern without needing ! at all — a more precise, more scalable solution than sprinkling ! throughout your codebase.)

It’s a code smell — often papering over a real bug — when it’s used reflexively just to silence a warning without actually verifying the assumption behind it. If you find yourself typing ! purely because the compiler is complaining and you want the complaint to stop, that’s exactly the situation where you’re most likely to be quietly disabling a warning that was correctly flagging a genuine risk. A good habit: every time you write !, be able to state in one sentence why you’re certain the value can’t be null at that specific point — if you can’t articulate that reason, it’s a strong signal to add an actual check instead of suppressing the warning.

! used on more than just simple variables

The null-forgiving operator can be applied to any expression, not just a bare variable, which is worth knowing so you can recognise it in the wild:

var firstAdult = people.FirstOrDefault(p => p.Age >= 18)!; // asserts the result won't be null, suppressing the warning

This particular example is a common trap: FirstOrDefault returns null when no element matches the predicate, so asserting the result away with ! here is only safe if you’ve independently verified, through other logic, that a match is guaranteed to exist — otherwise you’ve silenced a warning that was correctly describing a real possibility.

Nullability Attributes: Precisely Describing a Method’s Null Behavior

Sometimes a method’s nullability can’t be fully captured by a simple ? or its absence — the actual behavior depends on what happens when the method runs, not just its static signature. C# provides a small set of attributes, primarily used by library authors, that let you describe this precisely so the compiler’s flow analysis can follow along correctly at every call site, without callers needing ! at all.

[NotNullWhen(bool)] — describes an out parameter (or return value) that is guaranteed non-null specifically when the method returns a particular bool value. This is exactly how TryGetValue-style methods are annotated in the standard library:

bool TryGetName(int id, [NotNullWhen(true)] out string? name)
{
if (id == 1) { name = "Alice"; return true; }
name = null;
return false;
}

if (TryGetName(1, out string? result))
Console.WriteLine(result.Length); // no warning here — the compiler trusts the attribute's promise

Without this attribute, the compiler would still warn about result.Length, since result’s declared type is string?. The attribute tells the compiler, precisely, “whenever this method returns true, treat that out parameter as non-null from that point forward” — letting the compiler’s flow analysis stay accurate without any ! needed anywhere at the call site.

[MaybeNull] — the opposite kind of promise: marks something whose declared type looks non-nullable, but which might actually return null at runtime anyway (often used for backward compatibility with older, unannotated APIs, or generic code where T might turn out to be a reference type).

[NotNull] — asserts that a parameter or return value will never be null, even though its declared type technically allows it (string?) — commonly used on out/ref parameters that a method always assigns a real value to before returning, regardless of input.

[AllowNull] / [DisallowNull] — used on parameters to describe input nullability independently of a property’s own get/set nullability — for example, a property whose setter accepts null (which then gets normalised to an empty string internally) but whose getter never returns null.

[DoesNotReturn] — marks a method that never returns normally (it always throws), which lets the compiler correctly treat any code after a call to it as unreachable — useful for custom guard-clause helper methods:

[DoesNotReturn]
void ThrowIfInvalid(string? input)
{
if (input is null) throw new ArgumentNullException(nameof(input));
throw new InvalidOperationException(); // this method always throws, one way or another
}

You won’t need most of these day-to-day as an application developer — but recognizing them when you see them in .NET’s own source, or in a well-annotated third-party library, is what lets you understand why the compiler correctly avoids warning you in some cases and correctly does warn you in others, rather than it feeling arbitrary.

Guard Clauses and Trusting the Type System

In real code, you’ll constantly face a judgement call: should you defensively check every argument for null at the top of every method, or trust that the type system (via nullable reference type annotations) has already ruled null out?

A common, concise pattern for explicit, defensive checks at the boundaries of your code — especially public APIs where callers might not respect your nullable annotations, might be calling from an oblivious (pre-NRT) context, or might be passing data that came from outside your program’s control entirely (deserialised JSON, user input, a database row) — is:

public void ProcessOrder(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
// from this point on, customer is guaranteed non-null, with a clear, immediate exception if it wasn't
Console.WriteLine(customer.Name);
}

ArgumentNullException.ThrowIfNull is a concise, standard-library way to fail fast and loudly, with a clear exception message naming exactly which argument was the problem — far more useful for debugging than an anonymous NullReferenceException thrown from somewhere deep inside the method body later on, potentially far removed from where the actual bad value entered your code.

The broader judgement call — defend everywhere vs. trust the type system — doesn’t have one universally correct answer, but a reasonable default is: check defensively at the boundaries of your code (public APIs, deserialised data, anything coming from outside your own program’s control), and trust your non-nullable annotations internally, once you’re confident your codebase takes nullable warnings seriously (ideally with warnings-as-errors enabled). Checking null obsessively on every single private method call, when the type system has already given you strong non-nullable guarantees internally, adds clutter without meaningfully reducing risk — but skipping checks entirely at your public boundaries, where you don’t control what callers pass in, removes a safety net you likely still need.

Required Properties: A Different Tool for “This Must Be Set”

Nullable reference type warnings are about whether a reference might be null. A related but genuinely distinct problem is object initialisation: how do you guarantee a property gets a value at all, without necessarily making it nullable? Since C# 11, the required modifier addresses this directly:

class Customer
{
public required string Name { get; init; }
public string? Nickname { get; init; } // genuinely optional
}

var c = new Customer { Nickname = "Al" }; // compile error! Name is required and wasn't set
var c2 = new Customer { Name = "Alice" }; // fine — Name provided, Nickname left null

This is a compile-time-enforced guarantee that a property must be explicitly set during construction, which is a stronger and more precise tool than simply leaving a property non-nullable and hoping every constructor path sets it — required makes the compiler actively check every object-creation site for a missing assignment, rather than relying on nullable-warning flow analysis to eventually notice a gap, which it might not always catch depending on how the object gets constructed.

null vs. default — A Related but Distinct Idea

It’s worth clearly separating null from a closely related but different concept: default(T), or its shorthand default. default means “the zero-initialised value for type T” — for reference types, that value happens to be null, but for value types, it’s a real, valid value (0 for int, false for bool, all-zero fields for a struct), not an absence of anything.

string s = default; // null — the default for any reference type is null
int i = default; // 0 — a completely valid, ordinary int value, not an absence of a value
Point p = default; // a Point with all fields zeroed out, if Point is a struct — still a real Point

This distinction matters because it’s easy to conflate “no value was provided” with “the default value was provided,” and they aren’t the same thing for value types: a Point that’s default is a perfectly real, usable Point sitting at the origin — it’s not “missing” the way Nullable<Point> being null would be. If you need to distinguish “a real, valid zero value was explicitly provided” from “no value was provided at all” for a value type, default alone cannot do that — you need Nullable<T> (or T?) specifically, because only Nullable<T> carries an explicit HasValue flag separate from the value itself.

Null in Collections: A Design Convention Worth Adopting

One more practical distinction worth internalising, since it comes up constantly in real code: an empty collection and a null collection mean two different things, and conflating them is a common source of bugs and defensive-code clutter.

List<string> tags = new(); // an empty list — "this customer has zero tags," a known, valid state
List<string>? tags2 = null; // no list at all — "we don't know this customer's tags," a fundamentally different state

A very widely adopted convention in professional C# code is: methods that return collections should return an empty collection instead of null, whenever there’s a meaningful “nothing here” case, specifically so every caller can safely foreach over the result without needing a null check first:

// Preferred:
public List<string> GetTags(Customer customer) => customer.Tags ?? new List<string>();

// Avoid, if at all possible:
public List<string>? GetTags(Customer customer) => customer.Tags; // forces every caller to null-check before iterating

This convention doesn’t apply universally — sometimes “we don’t know” genuinely needs to be distinguished from “we know there are zero” — but as a default, it eliminates an enormous number of unnecessary null checks scattered throughout a codebase, and it’s worth adopting deliberately rather than by accident.

Summary

null represents an empty reference — literally, a variable holding no address at all — which is why the concept applies naturally to reference types (which hold an address to data stored elsewhere) but not to plain value types (which hold their data directly, with no address to leave empty). Nullable<T> gives value types a real, runtime opt-in mechanism for representing absence anyway, implemented as an actual struct carrying a genuine HasValue flag, complete with lifted operators and a special boxing rule that makes a null Nullable<T> box to an actual null reference. Nullable reference type annotations (string?) work in a completely different way: they’re a compile-time-only warning system, controlled by the nullable context, with zero effect on runtime behaviour — a string can still be assigned null and still throws a NullReferenceException exactly as before, and the annotation only changes what the compiler warns about while you’re writing the code, not what’s enforced when it runs, unless your build explicitly treats those warnings as errors. For checking null, prefer is null over == null since it can’t be fooled by a type’s overloaded == operator, lean on ?./??/??= and pattern matching for concise handling, and reach for the Try-pattern over nullable returns when a lookup might reasonably fail. The null-forgiving operator ! does nothing at runtime — it only silences a compiler warning — so treat it as a deliberate, justifiable assertion, not a routine fix, and prefer the [NotNullWhen]/[NotNull]-style attributes when you’re the one writing an API whose nullability depends on its behaviour rather than its static signature. Keep default conceptually separate from null (a value type’s default is a real, valid value, not an absence of one), default to returning empty collections rather than null ones wherever “nothing here” is a meaningful, expected state, defend explicitly against null at the boundaries of your code where you don’t control incoming data, and trust your non-nullable annotations internally once your team takes warnings seriously.

C# class vs record vs struct vs record struct

Why This Matters When AI Can Just Write the Code

It’s a fair question: if you can ask an AI assistant to generate a class, a record, or a struct in seconds, why spend time learning the difference yourself?

An AI tool will happily generate any of the four — a class when you needed a record, a mutable record struct when you needed a readonly one, a struct the size of a small database row when a class would have been far cheaper to pass around. It will do this confidently and without warning you, because generating syntactically valid code isn’t the same as generating the right code for your situation. The tool doesn’t know whether your Order needs identity that persists over time or whether two orders with equal values really are “the same order.” Only you know that, because it depends on what your program actually needs to do — and that’s a design decision, not a syntax problem.

There’s also a more immediate, practical reason: you cannot evaluate, debug, or safely modify code you don’t understand — whether you wrote it or an AI did. If an AI hands you a record struct and a bug shows up because a value silently mutated somewhere it shouldn’t have, you need to already know that record struct is mutable by default to even suspect where to look. If a HashSet<T> lookup mysteriously starts failing after an update, you need to already understand the connection between mutability and hashing to diagnose it, rather than pasting the bug back into a chat window and hoping. AI is a powerful accelerant for someone who already has the judgement to steer it and the literacy to check its output — it’s a much weaker tool, and sometimes actively harmful, in the hands of someone using it as a substitute for that judgement rather than an extension of it.

The reasoning in this post — reference vs. value semantics, structural vs. identity equality, the cost of copying, the fragile-base-class-style thinking around mutability — isn’t C#-specific trivia. It’s how you’ll evaluate design tradeoffs in any language you touch for the rest of your career, AI-assisted or not. Learning to ask “does this thing have an identity, or is it just data?” is a transferable skill. Knowing the exact keyword to type is not, and that part genuinely is something a tool can help with once you already know what you’re asking for.

The Core Mental Model

Every C# type sits at the intersection of two independent ideas, and understanding these two ideas is the key to understanding everything else in this guide.

The first idea is storage and copy semantics: is the type a reference type or a value type? A reference type lives on the heap, and a variable of that type doesn’t hold the object itself — it holds a reference (essentially an address) pointing to where the object lives. When you assign one reference-type variable to another, you’re copying the address, so both variables end up pointing at the same object. A value type, by contrast, lives directly wherever it’s declared — on the stack, or inline inside an array or another object. When you assign one value-type variable to another, the entire contents get copied, producing two completely independent values.

The second idea is equality: when you compare two instances with == or .Equals(), does the language check whether they’re the same object (reference/identity equality), or whether they contain the same data (structural/value equality)?

These two axes combine to describe our four type kinds:

  • class is a reference type with reference equality by default. Two class instances with identical field values are still considered “different” unless you override equality yourself.
  • struct is a value type with value equality — but that default equality comes from System.ValueType and is implemented via reflection, which is functionally correct but noticeably slow.
  • record is a reference type, just like class, but the compiler automatically generates fast, correct, structural (value) equality for you, along with a few other conveniences.
  • record struct is a value type, just like struct, but again with the compiler automatically generating fast, correct, structural equality (no reflection this time either).

So a helpful way to think about it: a record is a class that the compiler has enriched with value semantics, and a record struct is a struct that the compiler has enriched in the same way. The “record” part of the name always means “give me generated equality, a readable ToString, and with-expressions” — it’s orthogonal to whether the underlying thing is a reference type or a value type.

Reference Types vs. Value Types, Explained Slowly

Since this distinction underlies everything else, it’s worth walking through carefully with an example, because it’s the part students get wrong most often.

class PointClass { public int X, Y; }

var a = new PointClass { X = 1, Y = 2 };
var b = a; // b now points to the SAME object as a
b.X = 99;
Console.WriteLine(a.X); // prints 99! Changing b changed a, because they're the same object

With a class, the variable a never actually contains a PointClass — it contains a reference to a PointClass sitting somewhere on the heap. When we write var b = a;, we’re copying that reference, not the object. Now a and b are two different “pointers” aimed at one shared object, so mutating through b is visible through a too.

Now compare that with a struct:

struct PointStruct { public int X, Y; }

var a = new PointStruct { X = 1, Y = 2 };
var b = a; // b gets an independent COPY of a's data
b.X = 99;
Console.WriteLine(a.X); // still prints 1 — a and b are separate values now

Here, a directly holds the X/Y values (no indirection through a heap address). When we write var b = a;, the runtime copies all of a’s bytes into b. From that point on, a and b are completely unrelated — changing one has no effect on the other. This copying happens every time you pass a struct into a method, return it from a method, or store it into a new variable, which is why struct size matters for performance: a big struct means a lot of copying.

This distinction directly explains why reference types can be null and value types generally can’t: null means “this reference doesn’t point to anything,” which is meaningless for a value type that isn’t a reference at all — it always has to hold some actual data.

record follows the class behavior above exactly (it’s a reference type), and record struct follows the struct behavior exactly (it’s a value type). Nothing about the word “record” changes this — it only changes what happens when you compare two instances or print them, which is the next topic.

Equality — Worked Examples for All Four

class: reference equality by default

class PointClass { public int X, Y; }

var a = new PointClass { X = 1, Y = 2 };
var b = new PointClass { X = 1, Y = 2 };
var c = a;

Console.WriteLine(a == b); // False: different objects, even though the data matches
Console.WriteLine(a.Equals(b)); // False: same reason
Console.WriteLine(a == c); // True: c is literally the same object as a

This often surprises beginners: a and b look identical on paper, but == returns false. That’s because class equality by default asks “are these the exact same object in memory?” — not “do these objects contain the same values?” If you want value-based comparison on a plain class, you have to override Equals, GetHashCode, and optionally ==/!= yourself, which is real, non-trivial boilerplate that’s easy to get subtly wrong (forgetting a field, mishandling null, inconsistent hash codes, etc.).

struct: value equality, but slow by default

struct PointStruct { public int X, Y; }

var a = new PointStruct { X = 1, Y = 2 };
var b = new PointStruct { X = 1, Y = 2 };

Console.WriteLine(a.Equals(b)); // True — value equality, as you'd hope
// But notice:
// Console.WriteLine(a == b); // Compile error! CS0019 — a struct doesn't even get a `==` operator by default

Good news: Equals does compare values correctly out of the box, because struct inherits it from System.ValueType, which uses reflection to inspect and compare every field. Bad news: reflection is slow — noticeably so if this Equals gets called in a hot loop or inside a HashSet/Dictionary. There’s also no == operator generated at all, so you can’t even write the natural comparison syntax without adding it yourself. In professional code you’d typically implement IEquatable<T> plus operator overloads by hand for any struct you plan to compare often — which is precisely the boilerplate record struct eliminates.

record: fast, compiler-generated value equality

record PointRecord(int X, int Y);

var a = new PointRecord(1, 2);
var b = new PointRecord(1, 2);

Console.WriteLine(a == b); // True — the compiler generated a real == operator for us
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False — they're still two separate heap objects

Notice the last line: a and b are still distinct objects living at different heap addresses (record is a reference type, remember), but the compiler-generated Equals/== compare their contents rather than their addresses, so the comparison behaves the way most people intuitively expect.

Records also correctly take runtime type into account when there’s inheritance involved, which matters a lot once you start building hierarchies:

record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name);

Animal a = new Dog("Rex", "Lab");
Animal b = new Dog("Rex", "Lab");
Animal c = new Animal("Rex"); // same Name, but a plain Animal, not a Dog

Console.WriteLine(a == b); // True — same runtime type (Dog) and same field values
Console.WriteLine(a == c); // False — even though "Name" matches, the runtime types differ

record struct: fast value equality, no reflection

record struct PointRecordStruct(int X, int Y);

var a = new PointRecordStruct(1, 2);
var b = new PointRecordStruct(1, 2);

Console.WriteLine(a == b); // True — compiler-generated, field-by-field comparison, no reflection involved

This is the “best of both worlds” case for equality: you get the value semantics of a struct (no heap allocation, independent copies) combined with the fast, correct, compiler-written comparison logic that record provides. You never have to write Equals/GetHashCode/== by hand, and you never pay the reflection tax that plain struct equality incurs.

ToString() — Why Records Are So Much Nicer to Debug

When you print a plain class or struct without overriding ToString(), you get almost nothing useful:

class PersonClass { public string Name = "Alice"; public int Age = 30; }
Console.WriteLine(new PersonClass());
// Output: PersonClass <- just the type name, no data at all

Records generate a genuinely useful ToString() automatically, listing every property and its current value:

record PersonRecord(string Name, int Age);
Console.WriteLine(new PersonRecord("Alice", 30));
// Output: PersonRecord { Name = Alice, Age = 30 }

The exact same thing happens for record struct. This might look like a small convenience, but in practice it’s one of the biggest quality-of-life differences students notice immediately: when you’re debugging, logging, or writing a failing test assertion, seeing PersonRecord { Name = Alice, Age = 30 } printed straight to the console instead of just PersonRecord saves an enormous amount of time you’d otherwise spend writing Console.WriteLine($"{p.Name}, {p.Age}") by hand, or attaching a debugger just to inspect field values.

Mutability & with-Expressions

record is immutable by default

When you declare a record using the concise positional syntax, each parameter becomes a property that can only be set once, at construction time (technically, it’s init-only rather than get; set;):

record Person(string Name, int Age);

var alice = new Person("Alice", 30);
// alice.Age = 31; // Compile error! Age can only be set during initialization, never after

This is a deliberate design choice, not an accident. Once you’ve built a Person, nothing else in your program can quietly reach in and change its Age behind your back — which eliminates an entire category of bugs where one part of a codebase mutates shared data and another part is surprised by it. It also makes instances inherently safe to share across threads, since there’s no mutable state to synchronise.

But you still need a way to “change” a value conceptually — for example, “give me a copy of Alice, but one year older.” That’s what with is for:

var olderAlice = alice with { Age = 31 }; // creates a brand-new object
Console.WriteLine(alice);      // Person { Name = Alice, Age = 30 } — completely unchanged
Console.WriteLine(olderAlice); // Person { Name = Alice, Age = 31 } — a new, separate instance

with takes the original object, copies every property you didn’t mention, overwrites the ones you did mention, and hands you a new object — all in one expression, with no manual copy-constructor to write or maintain.

record struct is MUTABLE by default — the single biggest gotcha in this whole topic

Students coming from record naturally assume record struct behaves the same way. It does not, and this trips up almost everyone the first time:

record struct Point(int X, int Y);

var p = new Point(1, 2);
p.X = 99; // Perfectly legal! record struct properties are ordinary mutable get/set by default
Console.WriteLine(p); // Point { X = 99, Y = 2 }

Unless you explicitly say otherwise, a record struct’s positional properties are regular, mutable { get; set; } properties — the exact opposite of what record does. If you want a record struct to behave immutably, the way record does automatically, you need to add the readonly modifier yourself:

readonly record struct Point(int X, int Y);

var p = new Point(1, 2);
// p.X = 99; // Now this is a compile error, exactly like the record case
var moved = p with { X = 99 }; // use `with` instead, same syntax as before

The practical takeaway for students: when in doubt, write readonly record struct, not just record struct, unless you have a specific, deliberate reason to want a mutable value type (a common one being a small “accumulator” struct you intentionally mutate in place inside a tight loop to avoid allocating a new instance on every iteration).

class and struct have no with support at all

Neither plain class nor plain struct gets any of this for free. If you want non-destructive “copy with one field changed” behaviour on a class, you write it by hand:

class PersonClass
{
public string Name; public int Age;
public PersonClass Clone(int newAge) => new PersonClass { Name = Name, Age = newAge };
}

This works, but it’s boilerplate that has to be manually kept in sync every time you add a new field — miss one, and your “clone” silently drops data. This is exactly the class of bug the compiler-generated with on records eliminates.

Inheritance — What’s Allowed and What Isn’t

class supports the full, familiar inheritance model you’d expect from an object-oriented language: unlimited depth, abstract base classes, virtual/override methods, sealed to stop further derivation, and so on.

class Shape
{
public virtual double Area() => 0;
}
class Circle : Shape
{
public double Radius;
public override double Area() => Math.PI * Radius * Radius;
}

record also supports inheritance, but with one important restriction: a record can only inherit from another record, never from a plain class (and vice versa). Within that restriction, it behaves a lot like class inheritance, and it pairs beautifully with pattern matching for modelling a fixed set of related shapes — often called “algebraic data type” style modelling:

abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;

Shape s = new Circle(2.0);
var area = s switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
_ => 0
};

struct and record struct, on the other hand, cannot inherit from anything (other than implementing interfaces) and are implicitly sealed. This isn’t a missing feature so much as a direct consequence of being a value type: inheritance relies on the idea that a Derived object can be substituted anywhere a Base reference is expected, which only makes sense when you’re dealing with references pointing at objects on the heap. Value types don’t have that kind of substitutability, so the language doesn’t offer struct inheritance at all. Both struct and record struct can still implement interfaces, though:

interface IShape { double Area(); }
record struct Circle(double Radius) : IShape
{
public double Area() => Math.PI * Radius * Radius;
}

Pattern Matching & Deconstruction

Because positional records generate a Deconstruct method automatically, you can immediately unpack them into separate variables, or use them in pattern-matching expressions, without writing any extra code:

record Point(int X, int Y);

var p = new Point(3, 4);
var (x, y) = p; // automatic deconstruction, thanks to the compiler-generated Deconstruct
Console.WriteLine($"{x},{y}"); // 3,4

if (p is (3, 4))
Console.WriteLine("Origin offset match!");

if (p is { X: > 0, Y: > 0 })
Console.WriteLine("In the first quadrant");

The same automatic deconstruction applies equally to record struct. A plain class or struct, by contrast, gets none of this unless you write a Deconstruct method yourself:

class PointClass
{
public int X, Y;
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}

Property patterns (the { X: > 0, Y: > 0 } syntax above) work on all four kinds of types — that part isn’t record-specific — but the tuple-style positional patterns and automatic var (x, y) = p; deconstruction specifically rely on the Deconstruct method that only records generate for you automatically.

Boxing — A Hidden Performance Trap for Value Types

“Boxing” is what happens when a value type gets wrapped up and placed on the heap so it can be treated as an object or through an interface reference. It’s easy to trigger without realising it:

struct PointStruct { public int X, Y; }

PointStruct p = new() { X = 1, Y = 2 };
object boxed = p; // BOXING happens here — a heap allocation is created
IComparable c = someComparableStruct; // also boxes, if the struct implements IComparable and is assigned through the interface

// The exact same thing can happen with record struct:
record struct PointRS(int X, int Y);
object boxedRs = new PointRS(1, 2); // also boxed

This matters because one of the main reasons people choose a value type in the first place is to avoid heap allocation — but if that value type frequently gets stored in a non-generic collection, assigned to an object variable, or passed through an interface, it quietly gets boxed anyway, and you lose the performance benefit you were trying to gain. class and record, being reference types already, never have this problem — assigning one to an object or interface variable is just copying a reference, with no extra allocation. This is one of the concrete reasons to think carefully before reaching for struct/record struct in code where the value will be stored generically or passed around as an interface a lot.

Performance Rule of Thumb: When Struct Copying Gets Expensive

Value types are copied every time they’re assigned, passed into a method, or returned from one. For a small struct like two ints, that copy is essentially free — a couple of machine words. But as a struct grows larger, that “free” copy stops being free:

// Cheap to copy — a reasonable struct candidate (16 bytes: two doubles)
readonly record struct Vector2D(double X, double Y);

// A poor struct candidate — copying this on every assignment, every method call,
// and every time it's added to a collection is expensive:
struct BigDataStruct
{
public Guid Id;
public string[] Tags; // this field is a reference, but the struct itself is still copied wholesale
public DateTime Created;
public decimal[] Amounts;
public byte[] Payload;
}
// -> this should be a `class` or `record` instead, so only a reference gets copied around

A commonly cited (though not strict) guideline is that once a value type grows past roughly 16 bytes, or gets copied very frequently, the copying overhead tends to outweigh the benefit of avoiding heap allocation, and you’re usually better off switching to a reference type (class/record).

Nullability

Because reference types are, at their core, just an address pointing at something, it’s entirely reasonable for that address to point at “nothing” — that’s exactly what null represents:

class PersonClass { }
record PersonRecord;

PersonClass? c = null; // fine
PersonRecord? r = null; // fine — record is a reference type under the hood

Value types don’t have this option, because there’s no “address” to leave empty — the variable directly is the data, and it must contain some value at all times:

struct PersonStruct { }
record struct PersonRecordStruct { }

PersonStruct s = null; // compile error, CS0037
PersonRecordStruct rs = null; // compile error, CS0037

PersonStruct? sNullable = null; // fine — wrapping in Nullable<T> adds an explicit "has no value" flag
PersonRecordStruct? rsNullable = null; // fine, same reasoning

PersonStruct? isn’t magic — it’s shorthand for Nullable<PersonStruct>, a small wrapper type that stores your value plus a separate boolean flag saying whether a value is actually present. This is a fundamentally different mechanism from reference-type null, even though the ? syntax looks identical in both cases.

Records vs. Record Structs — Revisiting the Immutability Difference

It’s worth returning to this point one more time, in its own section, because it’s the detail students most often get wrong on an exam or in a code review: a plain record’s positional properties are init-only, meaning they can only be assigned during construction and never again — so a record is immutable by default with no extra effort. A record struct’s positional properties, on the other hand, are ordinary mutable get; set; properties by default, so a record struct is not immutable unless you explicitly add the readonly modifier, either on the whole declaration (readonly record struct Point(int X, int Y);) or on individual members. Only once you’ve added readonly does a record struct become truly immutable and forbid reassigning its properties after construction, matching what record does automatically. If your goal is an immutable value object, the safe habit is to always write readonly record struct rather than relying on memory to add it later.

Choosing the Right Type — How to Decide

Start by asking yourself two questions about the type you’re designing, in order.

Question one: does this thing have an identity that matters independently of its data, and might it change over time? If you’re modelling a Customer, an Order, a background EmailService, or a DbContext, the answer is yes — two customers with the same name genuinely are two different customers, and an order’s status legitimately changes as it moves through your system. In that case, reach for a class. You want reference semantics (so everyone sharing the object sees the same updates) and you don’t need — and probably don’t want — structural equality baked in by default.

If instead the thing you’re modelling is really just data — where two instances containing the same values genuinely represent the same thing, like a Money amount, a coordinate, an event that happened, or a request/response payload — move to question two.

Question two: is this data small and copied frequently enough that avoiding heap allocation actually matters for performance, and do you not need inheritance? If yes — think coordinates, small math/geometry types, keys used heavily in a HashSet/Dictionary, currency values computed millions of times in a pricing loop — reach for readonly record struct. You get value-type performance (no GC pressure, independent copies) plus all the record conveniences (fast equality, readable ToString, with, deconstruction) with none of the reflection-based slowness a plain struct would give you by default.

If the answer to question two is no — the data is large, needs inheritance/polymorphism (for example, an abstract record Shape hierarchy), or will frequently be boxed/stored as object or through an interface — reach for a plain record instead. You keep all the same record conveniences, but as a reference type, so large payloads aren’t copied wholesale and boxing is never a concern.

Plain struct still has a place, but it’s a narrower one in modern C# (10 and later): reach for it specifically when you need low-level control that record struct doesn’t give you as cleanly — for example, precise interop layouts for P/Invoke via [StructLayout], or you’re intentionally avoiding the extra generated members for some specific reason. Absent one of those specific reasons, prefer readonly record struct over plain struct for new value types, since it gives you everything struct does plus fast equality, ToString, and with for free.

Full Syntax Reference

// ---- class ----
public class Car
{
public string Model { get; set; }
public Car(string model) => Model = model;
}

// ---- struct ----
public struct Car
{
public string Model { get; set; }
public Car(string model) => Model = model;
}
public readonly struct ImmutableCar
{
public string Model { get; }
public ImmutableCar(string model) => Model = model;
}

// ---- record (class) ----
public record Car(string Model); // positional, immutable, concise
public record class Car(string Model); // identical, explicit "class" keyword
public record Car { public string Model { get; init; } } // non-positional form

// ---- record struct ----
public record struct Car(string Model); // mutable properties by default!
public readonly record struct Car(string Model); // fully immutable — usually what you want

// ---- inheritance example (records only) ----
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Square(double Side) : Shape;

When and Why Use class

Use it when the type represents something with identity, lifecycle, or behaviour — not just a bundle of data.

Reach for class whenever identity matters more than value. Two Customer objects with the same name and email are still two different customers in the real world — reference equality is the correct semantic here, not a limitation to work around:

class Customer { public string Name; public string Email; }
var c1 = new Customer { Name = "Alice", Email = "a@x.com" };
var c2 = new Customer { Name = "Alice", Email = "a@x.com" };
// c1 and c2 are two distinct customer records in your system, even though the data matches.
// Reference equality (c1 == c2 is false) is exactly what you want here.

class is also the natural choice whenever a type has mutable state that changes over time — services, controllers, repositories, caches, connections, and anything else that gets updated in place (order.Status = OrderStatus.Shipped;). It’s the right tool whenever inheritance and polymorphism are core to the design: class hierarchies with virtual/override, abstract base classes, template-method patterns, and dependency-injected services implementing interfaces all rely on class semantics. It also fits large or complex object graphs well, since passing these around by reference avoids expensive copying, and lets multiple parts of the code share and mutate one instance (a DbContext, a Logger, a UI ViewModel). Finally, class is a reasonable default whenever you simply don’t need structural equality, or are willing to hand-roll Equals/GetHashCode for the rare cases where you do.

Typical uses: domain entities with a persistent identity (User, Order, Account), services (EmailService, PaymentGateway), ASP.NET Core controllers, ViewModels, repositories, anything wired through DI containers.

Avoid it when the type is just a small, interchangeable data value where two instances with equal data really are “the same thing” — that’s what record/struct are for.

When and Why Use record

Use it when the type represents an immutable value or a piece of data whose identity IS its content.

The clearest signal that you want a record is when value equality is the natural semantic for the thing you’re modelling. A Money(100, "USD") is the same value as another Money(100, "USD") — there’s no meaningful sense in which they’re “different objects”:

record Money(decimal Amount, string Currency);
var price1 = new Money(9.99m, "USD");
var price2 = new Money(9.99m, "USD");
Console.WriteLine(price1 == price2); // True — exactly the semantic you want

Immutability by default is another major reason to reach for record: because a record’s positional properties can’t be silently changed elsewhere in the code, you get compiler-enforced protection against a whole class of aliasing bugs, plus thread-safety for free, since immutable objects are inherently safe to share across threads without locking. On top of that, with-expressions make transformations concise and safe, replacing manual copy-constructor boilerplate with a single clear expression:

record OrderLine(string Sku, int Qty, decimal Price);
var line = new OrderLine("ABC123", 2, 19.99m);
var updated = line with { Qty = 3 }; // clear intent, no manual copy-constructor boilerplate

Records also give you a genuinely useful ToString() and equality out of the box, which pays off enormously in logging, debugging, and testing — Assert.Equal(expected, actual) on records “just works” structurally, with no need to compare field-by-field or override Equals in test fixtures. That combination makes record a natural fit for DTOs, API request/response models, events, and messages — anything that’s really “data plus identity-by-value,” where a UserCreatedEvent(Guid UserId, string Email) published twice with identical values should be treated as equal. Finally, records pair beautifully with pattern matching for algebraic-data-type-style modeling: an abstract record Shape with sealed record Circle/Rectangle subclasses lets you branch exhaustively and type-safely with a switch expression.

Typical uses: DTOs, API contracts, CQRS commands/queries, domain events, configuration snapshots, value objects in DDD (Address, Money, DateRange), discriminated-union-style modeling.

Avoid it when you need mutable, long-lived state, or your object has a distinct identity that persists independent of its field values — use class for those instead.

When and Why Use struct

Use it when you need a small, simple, performance-sensitive value type and don’t need any of the modern conveniences records add — or you’re targeting an older language version.

The most common reason to reach for struct is that avoiding heap allocation and GC pressure genuinely matters. In hot paths — tight loops, high-throughput services, game loops, numerical code — allocating thousands of small objects per second creates GC pauses that can hurt performance noticeably. A struct lives on the stack (or inline in an array or containing object) and simply doesn’t generate garbage the way repeatedly allocating small classes would:

struct Vector3 { public float X, Y, Z; }
Vector3[] particles = new Vector3[1_000_000]; // one contiguous block, no per-element heap allocation

struct is also the right choice when value copy semantics are exactly what you want: passing a struct to a method automatically gives that method its own independent copy, so there’s no risk of the callee mutating your data unexpectedly unless you explicitly pass it by ref. It’s essentially required for interop with unmanaged code and fixed memory layouts — struct supports [StructLayout], is necessary for P/Invoke signatures matching C structs, and works naturally with Span<T>/stackalloc scenarios. And of course, if you’re working in an older C# version (pre-C# 10) where record struct doesn’t exist yet, plain struct is your only option for value semantics. Finally, struct is a reasonable choice if you need default equality but don’t care about the reflection-based performance cost, or you’re planning to override Equals/GetHashCode/IEquatable<T> yourself anyway.

Typical uses: DateTime, Guid, TimeSpan-style primitives, math/graphics types (Vector2, Matrix4x4), pixel/colour structs, small fixed-size buffers, P/Invoke interop structs, enum-like value wrappers.

Avoid it when the type is large (rule of thumb: bigger than roughly 16 bytes), gets boxed frequently, needs inheritance, or you’d benefit from the auto-generated ToString/equality/with support that record struct provides. In nearly all new code targeting C# 10 or later, prefer record struct over a plain struct unless you have a specific reason not to, such as needing full manual control over generated members or interop attributes that conflict with the record struct’s generated code.

When and Why Use record struct

Use it when you want everything a struct gives you (stack allocation, value/copy semantics, no GC pressure) plus everything a record gives you (fast generated equality, readable ToString, with-expressions, deconstruction) — which is most of the time you’d reach for a struct in modern C#.

The strongest case for record struct is small immutable value objects that get compared or hashed a lot. Coordinates, money amounts, IDs, RGB colors, ranges — anything you’ll put in a HashSet<T> or use as a Dictionary<TKey, TValue> key benefits enormously from the compiler-generated, allocation-free Equals/GetHashCode:

readonly record struct Point(int X, int Y);
var visited = new HashSet<Point>();
visited.Add(new Point(1, 2));
visited.Contains(new Point(1, 2)); // True, fast, no boxing, no reflection

More broadly, record struct is the right choice whenever you want the ergonomics of records — concise positional syntax, with, ToString, deconstruction — without paying for heap allocation. This is the main reason record struct was added to the language in C# 10: plain struct never got these features, and hand-writing them for every small value type was tedious and error-prone. It’s an especially good fit for high-frequency value objects in performance-sensitive code that still benefit from readability — for example, a readonly record struct Money(decimal Amount, string Currency) used millions of times in a pricing engine gets no GC churn, but still keeps with, equality, and nice debug output.

The one thing to always keep in mind is the mutability gotcha covered earlier: pair record struct with readonly (readonly record struct) almost every time, unless you specifically want in-place field mutation — for example, a mutable accumulator struct updated in a tight loop to avoid allocating a new struct every iteration:

// Deliberately mutable — used as a scratch accumulator in a hot loop
record struct RunningTotal(decimal Sum, int Count)
{
    public void Add(decimal value) { Sum += value; Count++; }
}

Typical uses: geometry/math value types (Point, Vector2, Rect), currency/measurement value objects, small immutable keys for collections, tuples-with-names replacing (int, int) value tuples when you want named, equality-friendly semantics, hot-path DTOs in performance-critical services.

Avoid it when the type is large, needs inheritance/polymorphism, or is frequently boxed or stored as object/interface — those scenarios favour record (a reference type) instead.

Summary

If you remember nothing else from this guide, remember this: class and record are reference types that live on the heap and get copied by reference; struct and record struct are value types that live inline and get copied by value. The word “record” — on either kind — means “the compiler will generate fast structural equality, a readable ToString(), and with-expression support for you.” record is immutable by default because its positional properties are init-only; record struct is not immutable by default, because its positional properties are ordinary mutable properties — so write readonly record struct when you want a truly immutable value type, which is most of the time. Choose class when identity and mutable state matter; choose record when a piece of data’s value is its identity and you don’t need to avoid heap allocation; choose struct/record struct when the data is small, copied frequently, and you want to avoid GC pressure — and in modern C#, default to readonly record struct over plain struct unless you have a specific reason not to.