Are you sure you’re using inheritance correctly? In one of my projects, a single wrong base class cost us two sprint cycles – because the “cute” hierarchy looked nice on a whiteboard but fought reality in production.
If you’ve ever felt that OOP is either overkill or a magic wand, this guide will calibrate your intuition. We’ll master inheritance, polymorphism, abstraction, interfaces, and – equally important – composition over inheritance, with crisp C# examples you can drop into your codebase today.
Why read this
- You’ll learn when to use inheritance (and when not to).
- You’ll see battle-tested patterns for extensibility without regressions.
- You’ll leave with ready-to-paste C# snippets and a mental checklist that prevents architecture drift.
Promise: after this article you’ll be able to explain, implement and defend your OOP design choices in code reviews.
OOP in C#: the 60‑second refresher
Pillars:
- Encapsulation – hide data behind behavior.
- Abstraction – express intent via contracts (abstract classes/interfaces).
- Inheritance – reuse behavior via a base type.
- Polymorphism – call through a base contract, execute derived behavior.
Golden rule: prefer composition unless a strict is‑a relationship exists and you need polymorphism.
Inheritance done right (and wrong)
The essentials
public abstract class Animal
{
public string Name { get; }
protected Animal(string name) => Name = name;
public abstract string Speak(); // force derived classes to implement
public virtual void Move() => // optional override
Console.WriteLine($"{Name} moves.");
}
public class Dog : Animal
{
public Dog(string name) : base(name) { }
public override string Speak() => "Woof"; // required by abstract
public sealed override void Move() // sealed: no further overrides allowed
{
base.Move();
Console.WriteLine($"{Name} runs wagging tail.");
}
}
public class RobotDog : Dog
{
public RobotDog(string name) : base(name) { }
// Move() cannot be overridden here because Dog sealed it
}
Notes:
- Use
abstract
to require behavior in derived classes. - Use
virtual
to allow customization. - Use
sealed override
to stop the chain when further variation is risky. - Always call
base(..)
constructor intentionally; it’s part of the contract.
The smell of wrong inheritance
If you’re adding if (Type == ...)
branches inside the base class, you’re likely forcing a hierarchy where a strategy would fit better. Another red flag: needing different constructor parameters for different subtypes – this often points to drifting responsibilities.
Interfaces vs abstract classes
Interfaces describe capabilities; abstract classes provide common state/behavior.
public interface IFlyable
{
void Fly();
}
public abstract class Bird : Animal
{
protected Bird(string name) : base(name) { }
public override string Speak() => "Chirp";
}
public class Eagle : Bird, IFlyable
{
public Eagle(string name) : base(name) { }
public void Fly() => Console.WriteLine($"{Name} soars high.");
}
Guidelines:
- Use an interface when multiple unrelated types need the same capability.
- Use an abstract class when sharing state, invariants, or partial behavior.
- One class can implement many interfaces but derives from one base class.
Tip: Default interface methods exist, but keep them minimal – interfaces are contracts first.
Composition over inheritance (the survival rule)
Sometimes the world isn’t hierarchical. A Car
has an Engine
; it isn’t an Engine
.
public interface IEngine { void Start(); void Stop(); }
public class ElectricMotor : IEngine
{
public void Start() => Console.WriteLine("Silence. Ready.");
public void Stop() => Console.WriteLine("Power down.");
}
public class CombustionEngine : IEngine
{
public void Start() => Console.WriteLine("Vroom!");
public void Stop() => Console.WriteLine("Engine off.");
}
public class Car
{
private readonly IEngine _engine; // composition
public Car(IEngine engine) => _engine = engine;
public void Ignite() => _engine.Start();
public void Park() => _engine.Stop();
}
Why it wins:
- Swap implementations without subclassing
Car
. - Test with fakes easily (
IEngine
mock). - Avoids brittle deep hierarchies.
Practical polymorphism: replacing if
ladders with dispatch
Imagine price calculation that varies by product type. Don’t sprinkle switch
blocks everywhere – push behavior into the types.
public abstract class Product
{
public decimal BasePrice { get; }
protected Product(decimal basePrice) => BasePrice = basePrice;
public abstract decimal FinalPrice();
}
public class Book : Product
{
public Book(decimal basePrice) : base(basePrice) { }
public override decimal FinalPrice() => BasePrice; // 0% tax
}
public class Alcohol : Product
{
public Alcohol(decimal basePrice) : base(basePrice) { }
public override decimal FinalPrice() => BasePrice * 1.20m; // excise
}
// Client code: unaware of the concrete type
static decimal CheckoutTotal(IEnumerable<Product> items) =>
items.Sum(p => p.FinalPrice());
This is subtype polymorphism. The caller doesn’t know which override runs – and that’s the point.
Pattern matching + polymorphism (modern C#)
Polymorphism doesn’t replace pattern matching – they complement each other when you need data‑dependent logic.
static string Describe(Animal a) => a switch
{
Dog d => $"Dog: {d.Name} says {d.Speak()}",
Eagle e when e is IFlyable => $"Eagle: {e.Name} can fly",
Bird { Name: var n } => $"Bird: {n}",
_ => "Unknown creature"
};
Use patterns for one-off decisions; prefer virtuals/overrides for core behavior.
Overriding vs hiding (new
) – know the difference
class Printer
{
public void Print() => Console.WriteLine("Base print");
}
class FancyPrinter : Printer
{
public new void Print() => Console.WriteLine("Hidden print");
}
void Demo()
{
Printer p1 = new FancyPrinter();
p1.Print(); // Base print (static dispatch)
var p2 = new FancyPrinter();
p2.Print(); // Hidden print
}
Use new
sparingly; it hides a member and can confuse callers. If you intend polymorphism, use virtual
/override
.
Access modifiers that matter in hierarchies
private
– for the declaring type only.protected
– for the declaring type and derived types.internal
– within the same assembly.protected internal
– union of the two above.private protected
– derived types within the same assembly.
Prefer the narrowest visibility that satisfies your design.
Generics & variance in polymorphism
C# generics support variance for interfaces/delegates:
out T
(covariant) – you can useIEnumerable<object>
for a source ofstring
.in T
(contravariant) – you can useIComparer<string>
whereIComparer<object>
is expected.
IEnumerable<string> names = new[] { "Ana", "Bob" };
IEnumerable<object> objects = names; // ok: IEnumerable<out T>
Action<object> sink = o => Console.WriteLine(o);
Action<string> stringSink = sink; // ok: Action<in T>
Also mix generics with constraints to balance flexibility and safety:
public interface ISerializer<T>
{
byte[] Write(T value);
T Read(byte[] bytes);
}
public class JsonSerializer<T> : ISerializer<T> where T : class, new()
{
public byte[] Write(T value) => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(value);
public T Read(byte[] bytes) => System.Text.Json.JsonSerializer.Deserialize<T>(bytes)!;
}
Liskov Substitution: the iceberg that sinks designs
Classical trap: Bird
→ Penguin
. Penguins don’t fly; forcing Fly()
in the base breaks substitutability.
public abstract class Bird2 : Animal
{
protected Bird2(string name) : base(name) { }
// Wrong: public abstract void Fly(); // forces all birds to fly
}
public interface IFlyable2 { void Fly(); }
public class Swan : Bird2, IFlyable2
{
public Swan(string name) : base(name) { }
public override string Speak() => "Honk";
public void Fly() => Console.WriteLine("Flap flap");
}
public class Penguin : Bird2
{
public Penguin(string name) : base(name) { }
public override string Speak() => "Hrrr";
// No Fly() requirement — substitutability preserved
}
Lesson: do not put optional capabilities into base classes. Use small interfaces to model what varies.
Abstract factories with polymorphism (extensible, testable)
public interface IProductFactory
{
Product Create(string code, decimal price);
}
public class DefaultProductFactory : IProductFactory
{
public Product Create(string code, decimal price) => code switch
{
"BOOK" => new Book(price),
"ALC" => new Alcohol(price),
_ => throw new ArgumentOutOfRangeException(nameof(code))
};
}
public class Cart
{
private readonly IProductFactory _factory;
private readonly List<Product> _items = new();
public Cart(IProductFactory factory) => _factory = factory;
public void Add(string code, decimal price) => _items.Add(_factory.Create(code, price));
public decimal Total() => _items.Sum(p => p.FinalPrice());
}
Swap factories in tests to generate fakes without touching the Cart
.
When performance matters
- Deep virtual chains can block inlining. If a method shouldn’t be overridden, mark its override sealed to unlock potential optimizations.
- For hot paths where polymorphism isn’t needed, avoid virtual calls.
- Prefer interfaces only when you truly need substitution – they come with indirection that may affect micro‑hot code.
(Measure before you optimize – bring a profiler, not feelings.)
Defensive base classes
If you expose a base class in a library, protect invariants:
- Validate constructor arguments once in the base.
- Keep fields
private
+protected
accessors if needed. - Consider the template method pattern: define the algorithm in the base, delegate steps to virtual methods.
public abstract class ImportJob
{
public void Run()
{
var raw = ReadSource(); // step 1
var model = Parse(raw); // step 2
Persist(model); // step 3
}
protected abstract string ReadSource();
protected abstract object Parse(string raw);
protected virtual void Persist(object model) => Console.WriteLine("Saved");
}
A tiny class diagram (mental model)
Animal (abstract)
├── Dog : Animal
├── Bird : Animal
│ ├── Eagle : Bird, IFlyable
│ └── Penguin : Bird
└── (others...)
IFlyable (capability)
Keep capabilities (interfaces) separate from essences (base types).
Checklist: choosing the right tool
- Is it a strict is‑a? → Consider inheritance; otherwise pick composition.
- Do multiple unrelated types need the feature? → Interface.
- Do you share state and invariants? → Abstract base class.
- Will changes in a subtype break callers of the base? → Re‑think LSP.
- Do you need runtime variation? → Polymorphism/strategy.
- Do you need only compile‑time convenience? → Extension methods/records.
- Unsure? Start with composition; promote to inheritance later if needed.
Common pitfalls I keep seeing
- God base classes: everything derives from
BaseEntity
that does too much (logging, caching, validation). Split responsibilities. - Overriding to disable behavior: if you override just to throw
NotSupportedException
, your hierarchy is lying. - Hiding with
new
: creates surprising static dispatch. - Leaky constructors: derived classes that must call base methods to be valid – make invariants enforceable in the base constructor instead.
- Null‑unsafe contracts: mark reference properties
required
(C# 11+) or enforce via constructors; avoid half‑initialized objects.
Quick tests that save you later
[Fact]
public void Cart_Totals_Book_And_Alcohol()
{
var factory = new DefaultProductFactory();
var cart = new Cart(factory);
cart.Add("BOOK", 10m);
cart.Add("ALC", 10m);
Assert.Equal(22m, cart.Total());
}
If this test breaks after a “small” change, your polymorphism contract drifted – great catch in CI instead of prod.
FAQ: everyday design decisions
Use abstract class when you share state/invariants and want some default behavior. Use interfaces for orthogonal capabilities (e.g., IAuditable
). Often you’ll use both.
virtual
?Only when a reasonable derived implementation exists. If you can’t define clear expectations for an override, don’t make it virtual.
Only through interfaces. Classes have a single base class.
Seal what you can, keep base classes small, document invariants, and provide protected hooks rather than exposing fields.
Use records for immutable, value‑like aggregates (data with identity based on state). Use classes for rich behavior with polymorphism.
Yes. Prefer virtuals for core behavior; use patterns for orchestration or cross‑cutting decisions.
Conclusion: from “is‑a” to “works‑with” mastery
We covered the tools that matter: inheritance where it’s true, polymorphism where it simplifies clients, interfaces for capabilities, and composition for everything else. The win isn’t pretty class diagrams – it’s code that stays flexible without surprising callers.
Your next step: refactor one switch
or if
ladder into polymorphism, and one implicit capability into an interface. Ship it, measure it, and tell me how it felt.
Question for you: what’s the most painful hierarchy you’ve had to untangle, and how did you fix it?