C#: Inheritance vs Composition — When to Use Each and Why AI Can't Decide for You

Every C# developer eventually hits this question: should I use inheritance or composition here? And if you ask an AI — or search for articles online — you’ll get the standard answer: “favor composition over inheritance.” That advice isn’t wrong. But it isn’t enough, either.

A one-shot prompt can explain the theory behind both patterns. What it cannot do is reliably judge whether a specific class hierarchy in your application is stable, leaky, or already causing coupling problems. That judgment requires context that only the developer working inside the codebase actually has.

Let me break down both patterns, when each one is the right call, and why this remains a decision that requires human architectural thinking.

What Are Inheritance and Composition?

Before diving into when to use each, let’s be clear about what they actually do in C#.

Inheritance means one class (the derived or child class) extends another class (the base or parent class) using the : syntax in C#. The derived class automatically gets all the public and protected members of the base class — properties, methods, fields — without rewriting them. It can then add new members or override virtual/abstract members to specialize behavior.

Composition means a class holds a reference to another object (typically through a field or constructor parameter) and delegates work to it. Instead of inheriting behavior, the class uses another object’s behavior. The composed object is typically injected through the constructor, which makes it easy to swap implementations.

These two approaches solve fundamentally different design problems, and understanding that difference is what this article is about.

Inheritance: The “Is-A” Relationship

Inheritance models a strict “is-a” relationship. A SqlConnection is a DbConnection. A MemoryStream is a Stream. The base class defines a contract that derived classes extend or specialize.

public abstract class Notification
{
    public DateTime CreatedAt { get; } = DateTime.UtcNow;

    public abstract void Send(string recipient, string message);
}

public class EmailNotification : Notification
{
    public override void Send(string recipient, string message)
    {
        // Send via SMTP
    }
}

public class SmsNotification : Notification
{
    public override void Send(string recipient, string message)
    {
        // Send via SMS gateway
    }
}

This works well when:

  • The hierarchy is stable — you don’t expect the base class contract to change frequently.
  • Derived classes genuinely share identity and behavior with the base, not just a few convenience methods.
  • Polymorphism through the base type is a real requirement in calling code.

Composition: The “Has-A” Relationship

Composition models a “has-a” or “uses-a” relationship. Instead of inheriting behavior, a class holds a reference to another object that provides it.

public interface IMessageSender
{
    void Send(string recipient, string message);
}

public class SmtpSender : IMessageSender
{
    public void Send(string recipient, string message)
    {
        // Send via SMTP
    }
}

public class NotificationService
{
    private readonly IMessageSender _sender;

    public NotificationService(IMessageSender sender)
    {
        _sender = sender;
    }

    public void Notify(string recipient, string message)
    {
        // Add logging, retry logic, audit trail, etc.
        _sender.Send(recipient, message);
    }
}

This works well when:

  • Behaviors need to be swapped, combined, or configured at runtime.
  • You want to test components in isolation by injecting mocks or stubs.
  • The relationship between objects is about capability, not identity.
  • You anticipate that the “how” of a behavior will change independently from the “what” of the consuming class.

When Inheritance Goes Wrong: The Fragile Base Class Problem

The most common real-world failure with inheritance is called the Fragile Base Class Problem. It happens when changes to a base class unintentionally break derived classes — even when those changes seem safe.

Here’s a concrete example:

public class ReportGenerator
{
    public virtual string GenerateHeader()
    {
        return "REPORT - " + DateTime.Now.ToShortDateString();
    }

    public virtual string Generate()
    {
        var header = GenerateHeader();
        return header + "\n" + "Report body goes here.";
    }
}

public class AuditReportGenerator : ReportGenerator
{
    public override string GenerateHeader()
    {
        return "AUDIT REPORT - " + DateTime.Now.ToString("yyyy-MM-dd HH:mm");
    }
}

This looks reasonable. But now imagine a new developer modifies the base class:

public class ReportGenerator
{
    public virtual string GenerateHeader()
    {
        return "REPORT - " + DateTime.Now.ToShortDateString();
    }

    public virtual string Generate()
    {
        // Changed: no longer calls GenerateHeader()
        var header = "REPORT - " + DateTime.Now.ToShortDateString();
        return header + "\n" + GenerateBody();
    }

    protected virtual string GenerateBody()
    {
        return "Report body goes here.";
    }
}

Now AuditReportGenerator.GenerateHeader() is never called by Generate(), and the audit report silently produces the wrong header. The base class change was “internal” — but it broke the derived class because AuditReportGenerator depended on the base class’s implementation details, not just its public contract.

This is the fragile base class problem in action. The deeper your hierarchy, the more likely this kind of invisible breakage becomes.

The Liskov Substitution Principle (LSP)

There’s a well-known principle from SOLID that directly governs when inheritance is safe: the Liskov Substitution Principle. In plain terms, it means:

Any code that works correctly with a base class instance must also work correctly with any derived class instance — without knowing the difference.

If your derived class changes assumptions, throws unexpected exceptions, or requires special handling that the base class doesn’t, you’ve violated LSP. That’s a strong signal that inheritance is the wrong tool and composition would serve you better.

The Real Decision Framework

Here’s how to think about it in practice:

Use inheritance when:

  1. The base type is a stable abstraction unlikely to shift under you.
  2. Derived types genuinely share identity — not just a method or two.
  3. You actually need polymorphism through the base type in calling code.
  4. The hierarchy is shallow (one or two levels deep, rarely more).

Use composition when:

  1. Behaviors might be mixed, swapped, or extended independently.
  2. You’re combining capabilities from multiple sources (C# doesn’t have multiple inheritance for a reason).
  3. You need testability through dependency injection.
  4. The relationship is “this object uses that behavior” rather than “this object is that thing.”

Most of the time, composition wins. Not because inheritance is bad, but because real-world requirements tend to shift, and composition handles change with far less friction.

Why “Favor Composition” Isn’t the Full Answer

The phrase “favor composition over inheritance” comes from the Gang of Four, and it’s solid general advice. But “favor” doesn’t mean “always use.” There are cases where inheritance is the cleaner, simpler design:

  • Framework extension points that expect you to inherit (ASP.NET Controller, DbContext in EF Core).
  • Type hierarchies that are genuinely stable and well-understood in your domain.
  • Shared behavior that is inseparable from type identity.

The real skill is knowing which pattern fits the situation in front of you — and being willing to refactor when you got it wrong.

C# Features That Make Composition Easy

C# gives you several language features specifically designed to support composition:

  • Interfaces — Define contracts without implementation. A class can implement multiple interfaces, giving you the flexibility that multiple inheritance would (without the diamond problem).
  • Dependency Injection (DI) — ASP.NET Core’s built-in DI container makes it trivial to register and inject composed dependencies. Constructor injection is the standard pattern.
  • Default Interface Methods (C# 8+) — Interfaces can now provide default implementations, letting you add shared behavior to an interface without forcing a base class.
  • Extension Methods — Add behavior to types without inheritance or modifying the original class.

These features mean that in modern C#, you rarely need inheritance for code reuse. The language actively supports composition as a first-class approach.

Refactoring: From Inheritance to Composition

The conclusion of this article mentions that refactoring from inheritance to composition is easier than going the other direction. Let me show you what that looks like.

Before (inheritance):

public class OrderProcessor
{
    public virtual decimal CalculateDiscount(decimal total)
    {
        return total > 100 ? total * 0.1m : 0;
    }

    public void Process(Order order)
    {
        var discount = CalculateDiscount(order.Total);
        order.ApplyDiscount(discount);
        // ... process the order
    }
}

public class VipOrderProcessor : OrderProcessor
{
    public override decimal CalculateDiscount(decimal total)
    {
        return total * 0.2m; // VIPs always get 20%
    }
}

This seems fine — until you need a “holiday discount” that applies to both VIP and regular customers, or a “bulk discount” that stacks differently. Suddenly one inheritance hierarchy can’t express the combinations.

After (composition):

public interface IDiscountStrategy
{
    decimal CalculateDiscount(decimal total);
}

public class StandardDiscount : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal total)
    {
        return total > 100 ? total * 0.1m : 0;
    }
}

public class VipDiscount : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal total)
    {
        return total * 0.2m;
    }
}

public class OrderProcessor
{
    private readonly IDiscountStrategy _discountStrategy;

    public OrderProcessor(IDiscountStrategy discountStrategy)
    {
        _discountStrategy = discountStrategy;
    }

    public void Process(Order order)
    {
        var discount = _discountStrategy.CalculateDiscount(order.Total);
        order.ApplyDiscount(discount);
        // ... process the order
    }
}

Now you can add HolidayDiscount, BulkDiscount, or even a CompositeDiscount that stacks multiple strategies — all without touching OrderProcessor. That’s the power of composition: new behavior without modifying existing code.

Why AI Can’t Make This Call for You

Here’s the thing that most “ask AI” workflows miss: the decision between inheritance and composition depends on your specific codebase context.

  • How stable is your hierarchy? Is the base class changing every sprint?
  • Are derived classes overriding methods in fragile ways that break assumptions?
  • Is the coupling already causing pain — or is the current design actually fine?
  • What does the team understand and maintain well?

An LLM can explain the patterns. It can generate code examples for either approach. But it cannot look at your class hierarchy and tell you with confidence whether it’s going to hold up under next quarter’s requirements. It doesn’t know your team, your domain, your rate of change, or where the pain is coming from.

This is exactly the kind of judgment that separates someone who writes code from someone who architects systems. AI is a powerful tool for generating scaffolding, explaining concepts, and accelerating implementation. But architectural decisions — especially ones about coupling, stability, and future change — still require a human who understands the full context.

A Practical Heuristic

When I’m on the fence, I ask myself one question:

If I need to change this behavior six months from now, will inheritance make that change harder or easier?

If the answer is “harder” — if changing the base class risks breaking derived classes, or if I’d need to override most of the inherited behavior anyway — then composition is the right call. If the hierarchy is small, stable, and genuinely models identity, inheritance keeps things simple.

Quick Reference Summary

Factor Inheritance Composition
Relationship “Is-a” “Has-a” / “Uses-a”
Coupling Tight (base ↔ derived) Loose (interface-based)
Flexibility Fixed at compile time Swappable at runtime
Testability Harder to isolate Easy to mock/stub
Multiple behaviors Single base class only Combine multiple interfaces
Refactoring cost High (breaks derived classes) Low (swap implementations)
Best for Stable, shallow type hierarchies Configurable, evolving behavior

Conclusion

Inheritance and composition are both tools. Neither is universally right or wrong. The architectural judgment to choose correctly comes from understanding your system’s specific constraints — something no amount of pattern theory or AI-generated advice can replace.

Learn both patterns deeply. Use them both when appropriate. And when you’re unsure, remember: you can always refactor from inheritance to composition later — as the refactoring example above demonstrates. Going the other direction is usually much harder. That asymmetry alone is a good reason to default to composition when you’re uncertain.

Chris Pietschmann
Chris Pietschmann
Microsoft MVP (Azure & Dev Tools) | HashiCorp Ambassador | IBM Champion | MCT | Developer | Author

I am a solution architect, developer, SRE, trainer, author, and more. With 25 years of experience in the Software Development industry that includes working as a Consultant and Trainer in a wide array of different industries.