"Favor composition over inheritance" is a slogan, and like most slogans I have seen it repeated by the same engineers who keep writing six-level inheritance hierarchies because that is what the framework demanded. The slogan is right. The application of the slogan is what I want to talk about, because the gap between believing the saying and changing the code is larger than people admit.
I am going to take a contrary stance against the people who treat the slogan as absolute: inheritance is still the correct choice in some cases, and pretending otherwise leads to its own awkward code. My actual position is more like: composition is the default, inheritance is a precision tool with a narrow legitimate range ("is-a" relationships you would name out loud), and the failure mode I see most often is not over-using inheritance but mid-using it (for code reuse, when the relationship is not is-a). I will show what I mean.
The textbook smell
The textbook smell is something like this:
The Penguin breaks the inheritance chain, and the textbook authors gleefully use it to argue that inheritance is broken. It is not the most useful example, because nobody actually models penguins this way in production code. The real-world version of this bug is more subtle:
This is what "inheritance for code reuse" looks like in practice. Each subclass adds a layer of cross-cutting behavior. None of those classes is genuinely a kind of HttpClient in the is-a sense; they are all the same HTTP client with different decorations. By the time we are at MetricsHttpClient, the chain is rigid: changing the order of metrics-then-log is a refactor, adding a circuit breaker requires a fifth class, and replacing the retry strategy means subclassing somewhere new.
The composition refactor:
The layering is now a value, decided at construction. Reordering is a one-line change. Adding a new layer is one new function. There is no class hierarchy, and no chain of super.get(...) calls. This is the win the slogan is trying to capture.
What composition actually looks like at the field level
The HTTP client example is composition by wrapping. The other shape, less talked about, is composition by fields. Instead of making Order extend Auditable, make Order HAVE an Audit field. Instead of making User extend Geolocatable, make User HAVE a Location field.
The difference is small at one level and large at three. With inheritance, every "thing that can be audited" must inherit from Auditable, which means the codebase grows a parallel hierarchy that mirrors the domain hierarchy. With composition, the Audit collaborator is a field, and any object that needs auditing has the field. Two unrelated classes (Order and Refund) both have audit without sharing a parent. The hierarchy stays flat.
The rule that crystallized this for me: if the relationship is "the thing IS a kind of", inheritance might be right. If the relationship is "the thing HAS a", make it a field. Penguin IS a Bird (yes, even if it cannot fly, that is taxonomy). Order is NOT an Auditable; Order HAS audit-tracking. Most of the inheritance I see in real code is HAS confused with IS.
Where I keep using inheritance
Three cases I have not been able to refactor away with composition, and where I would not even try:
- Frameworks that require it.
extends React.Componentfor the older React class API,extends NSObjectfor Objective-C,extends Activityfor Android. The framework owns the inheritance contract. Fine, do the minimum extension and put your logic in fields and helper functions. - Genuine taxonomies. A
PaymentMethodinterface withCreditCardPayment,BankTransferPayment,WalletPaymentsubclasses is appropriate when each subclass is a real subtype with its own data and behavior. The hierarchy is one level deep. There is no hidden agenda about code reuse; each subclass is a real kind. (In TypeScript, I usually prefer a discriminated union over a class hierarchy here, but the principle holds: there is a real taxonomy to model.) - Sealed-class patterns (modeling closed sums). When the language supports it (Kotlin's sealed classes, Scala's case classes, TypeScript's discriminated unions, Rust's enums), "inheritance" with a finite, exhaustive set of subtypes is exactly the right tool, because the type system can prove you handled every case.
Notice all three cases share: shallow hierarchy (one or two levels), the subtypes really are kinds of the parent, no "add functionality by extending" intent.
The refactor steps when you have gone too deep
If you inherit a codebase with a four-level inheritance chain and you want to refactor, do not try to flatten it in one PR. The pattern that has worked for me, in three projects:
- Identify the shared state in the base class. Pull each piece of shared state into its own small class (
Audit,RetryPolicy,MetricsCollector). - Add a field for that collaborator on the parent class. Now the shared state lives in a field as well as inherited members. Both work; the codebase compiles and tests pass.
- Migrate call sites one at a time to use the field instead of the inherited method.
this.audit.record(...)instead ofthis.log(...). - Remove the inherited member from the base class once no caller uses it.
- If the base class is now empty, delete it. The subclasses become standalone classes that share no parent.
This works because each step is independently testable and revertible. The big-bang "rewrite everything" version of this refactor has failed every time I have seen it attempted.
A specific anti-pattern: abstract class with template methods
The Template Method pattern says: write an abstract class with the skeleton of an algorithm and abstract methods that subclasses implement to customize specific steps. This was a common 2000s shape:
The composition equivalent is a function that takes three callbacks:
No abstract class. No subclasses. The customization points are arguments, which means I can mix and match fetchSales with formatUsersJSON for an ad-hoc report without inheriting from anything. The Template Method pattern is one of the patterns where, in a language with first-class functions, the OO version is strictly worse.
A subtler trap: protected methods
The second trap inheritance pulls people into is the protected method. A base class exposes a protected helper, subclasses override it to customize behavior, and now the base class's invariants depend on what the subclass does. The base class's public API is fine, but the implicit contract between base and subclass is fragile. Every time you change the base class, you have to think about every subclass, even ones in other modules or other repos.
The composition equivalent is a callback or a strategy field:
The second form makes the variation explicit at construction. There is no protected method, no implicit contract, no inheritance. A test can construct an ApiClient with a stub serializer and assert the wire format directly, instead of subclassing the test target to get at the protected method. The hooks that used to be protected methods become first-class parameters of the constructor.
The rule I use: every time I see a protected modifier on a method, I ask whether that method should have been a constructor parameter or a strategy field. Eight times out of ten the answer is yes, and the refactor removes a chunk of inheritance that was not pulling its weight.
What the slogan misses
The slogan as commonly stated ("favor composition over inheritance") leaves out a key piece: composition has its own failure modes. A class that delegates to seven collaborator fields and adds nothing of its own is a pass-through, and that is also bad. Composition is not free; the right number of collaborators is small and named, and each one has to do real work. If your composition refactor turns one class into eight collaborators, you may have moved the complexity instead of reducing it.
The healthier framing I now use: the question is not "composition or inheritance". The question is "what is this class actually responsible for, and what does it delegate to others". Some classes have one collaborator. Some have none and just hold data. Some have a small number of methods and no fields beyond the input parameters. The point is to model the responsibility honestly, not to maximize either pattern.
Why I still bring it up in interviews
If I am interviewing a candidate and they reach for inheritance immediately, I do not ding them. If they reach for inheritance for code reuse (not for an is-a relationship), I ask why. The follow-up I want to hear is: "these are not really kinds of the same thing; they share behavior. Let me model the shared behavior as a collaborator instead." That answer is the operationalized version of the slogan, and it tells me the candidate has thought about the gap between the saying and the doing. The candidates who can do that translation under interview pressure are the ones who will not leave four-level inheritance chains in the codebase a year from now. That is the actual skill the slogan is gesturing at, and it is worth more than the slogan itself.
