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 — Employee → SalariedEmployee → Manager → SeniorManager, 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
Circlereally is a kind ofShapein 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 “aDuckcan 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 Shapewithsealed record Circle/Rectanglesubclasses, paired with aswitchexpression 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
Control→ButtonBase→Buttonhierarchy 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.