Composition vs Inheritance
How to Stop Overusing `extends`
LinkedIn Hook
Most developers learn inheritance and immediately overuse it.
They model everything as a hierarchy. Managers extend Employees. Electric cars extend Cars. Admin users extend regular users. It feels clean. It feels like real OOP.
Then the requirements change.
Suddenly an Employee also needs to be a Contractor. Suddenly a Car needs to be both Electric and Autonomous. Suddenly your hierarchy has become a cage — and every change breaks something three levels up.
This is the fragile base class problem. And it is why the Gang of Four wrote, in 1994: "Favor object composition over class inheritance."
Most developers have heard this rule. Few can explain what it means at a code level, when to apply it, and when inheritance is still the right call.
Lesson 4.6 covers all of it. Before/after refactoring included.
Read the full lesson -> [link]
#OOP #JavaScript #SoftwareEngineering #InterviewPrep #DesignPatterns
What You'll Learn
- What "Has-a" vs "Is-a" means and how to apply the test before writing any
extends - Why the Gang of Four said "favor composition over inheritance" and what problem they were solving
- How to refactor a brittle inheritance hierarchy into a flexible composition design, step by step
- When inheritance is still the right tool and you should not reach for composition
- What the fragile base class problem is and why it makes large hierarchies expensive to maintain
The Analogy — LEGO vs a Carved Statue
Imagine two ways to build a toy robot.
The first way: carve it from a single block of wood. The robot is a RobotToy, which is a MovingToy, which is a Toy. Everything is baked in. The robot can walk because walking is carved into it. If you want a robot that also flies, you need a new carving: FlyingRobotToy extends RobotToy. Want one that swims too? AmphibiousFlightRobotToy. The hierarchy grows with every new combination.
The second way: build it with LEGO bricks. You have a WalkingModule, a FlyingModule, a SwimmingModule. The robot is an assembly. It holds references to whatever modules you hand it at build time. Want a flying-swimming robot? Attach both modules. Want to swap the walking engine for a rolling wheel? Replace one brick. Nothing else breaks.
Inheritance is the carved statue. Composition is LEGO.
[IMAGE: Two-column illustration — left: a tree of class names growing downward with chains (carved statue approach), right: a modular assembly of labeled bricks snapping together (LEGO approach). Background dark #0a0a1a, neon green highlights on the composition side, neon orange on the inheritance side]
What is the "Is-a" vs "Has-a" Relationship?
Before writing a single line of code, ask one question: does the child class is-a version of the parent, or does it have-a capability from the parent? The answer determines which tool to reach for.
"Is-a" means the child is a genuine specialization of the parent. A Dog is an Animal. A Circle is a Shape. A SavingsAccount is a BankAccount. The relationship holds in plain language, it holds semantically, and it holds across all future uses of the child class. Anywhere you use the parent, the child should work correctly. This is the Liskov Substitution Principle, and inheritance satisfies it when the "is-a" test passes cleanly.
"Has-a" means the class needs a capability, not a new identity. A Car has an Engine. A User has a PermissionSet. A Logger has a Formatter. The capability is a component, not an identity. Swapping the component should not require changing the class's public contract. Composition models this correctly. Inheritance models it poorly.
The trap: many developers use inheritance when the real relationship is "has-a," because it feels like a shortcut. The parent has a method you need, so you extend it to get the method. That shortcut is where fragility begins.
[CHART: Decision flowchart — "Does the child fully satisfy 'is a [Parent]'?" Yes branch leads to "Passes LSP?" Yes leads to "Use Inheritance", No leads to "Use Composition". No branch leads directly to "Use Composition". Clean dark background, neon green for inheritance path, neon orange for composition path]
What is the Fragile Base Class Problem?
The fragile base class problem is what happens when a base class change breaks a subclass that had no reason to break. It is one of the most cited arguments against deep inheritance hierarchies, and interviewers bring it up specifically to test whether you understand the real cost of inheritance.
Here is the core situation. A subclass inherits from a base class and overrides some methods. The base class author later modifies a method the subclass does not override — perhaps to optimize it, or to fix a bug. That internal change alters the behavior the subclass was silently relying on. The subclass breaks, even though nobody touched the subclass. The base class was "fragile" because changing it was safe in isolation but unsafe in the context of its subclasses.
The problem compounds in deep hierarchies. A change at the grandparent level can ripple through every child and grandchild. Testing is expensive because you can't be confident that changing the base class is safe without testing every subclass. The hierarchy becomes resistant to change, which is the opposite of what OOP is supposed to deliver.
Composition avoids this problem structurally. A composed object holds a reference to a component. Changing the component's internal implementation does not affect the holder unless the component's public interface changes. The holder chose the component at construction time and calls it through a stable interface.
[PERSONAL EXPERIENCE]: In practice, the fragile base class problem almost always appears silently at first. Tests pass. Everything works. Then, three months later, a seemingly safe internal optimization in a base class method produces a subtle bug in a subclass that nobody connects to the base class change. The stack trace points to the subclass, and the investigation takes time to trace back up. Composition does not eliminate this category of bug, but it makes the dependency explicit and the coupling obvious.
The GoF Principle — "Favor Composition Over Inheritance"
The Gang of Four stated it in Design Patterns (1994): "Favor object composition over class inheritance." This is one of the two most repeated design principles in OOP, alongside "program to an interface, not an implementation." It appears in interviews regularly, and a good answer goes beyond quoting the rule.
The reasoning: class inheritance is a white-box reuse mechanism. Subclasses see (and often depend on) the internal structure of their parents. This creates tight coupling. Object composition is a black-box reuse mechanism. The composing object only knows the interface of its components, not their internals. Black-box reuse is more flexible and easier to change.
The word "favor" matters. The principle does not say "never use inheritance." It says prefer composition as your default tool, and reach for inheritance only when the "is-a" relationship is clear and stable.
[UNIQUE INSIGHT]: The GoF principle was written in 1994, when Java and C++ were the dominant languages, and deep class hierarchies were the first instinct of most OOP programmers. JavaScript's prototype chain and its lack of access modifiers make the fragile base class problem even more pronounced in JS: subclasses can access and override anything on the prototype, and there is no final keyword to seal a method. The "favor composition" rule applies more forcefully in JavaScript than it does in Java.
Before/After — Refactoring from Inheritance to Composition
This is the section most courses skip. Reading about the principle is easy. Seeing the refactoring step by step is what makes it stick, and what interviewers are actually testing when they ask for a practical example.
The Inheritance Version (Before)
We have a system that logs application events. Early on, a simple Logger handles all logging. Then requirements expand: some logs need timestamps, some need a JSON format, some need to write to a file, and some need all three. The instinctive response is to model this as an inheritance hierarchy.
// BEFORE: Inheritance-based Logger hierarchy
// Every new combination of features requires a new subclass.
class Logger {
log(message) {
console.log(message);
}
}
// Subclass 1: adds timestamps
class TimestampLogger extends Logger {
log(message) {
// Calls the parent log, but prepends a timestamp
super.log(`[${new Date().toISOString()}] ${message}`);
}
}
// Subclass 2: formats output as JSON
class JSONLogger extends Logger {
log(message) {
// Ignores super.log — formats entirely differently
console.log(JSON.stringify({ message, level: 'info' }));
}
}
// Problem appears here: we need BOTH timestamp AND JSON.
// We have to pick one parent. JavaScript does not allow extending two classes.
// So we create a combined subclass — which duplicates logic from both parents.
class TimestampJSONLogger extends Logger {
log(message) {
// Duplicates the timestamp logic from TimestampLogger
// Duplicates the JSON logic from JSONLogger
// Neither parent's log() is called — super is useless here
console.log(JSON.stringify({
message,
level: 'info',
timestamp: new Date().toISOString(),
}));
}
}
// Now requirements add: write to a file AND use JSON AND add timestamps.
// Another subclass: TimestampJSONFileLogger extends ???
// The hierarchy is already breaking down.
const basic = new Logger();
basic.log('Server started');
// Server started
const ts = new TimestampLogger();
ts.log('Request received');
// [2026-04-22T10:00:00.000Z] Request received
const json = new JSONLogger();
json.log('User logged in');
// {"message":"User logged in","level":"info"}
const tsJson = new TimestampJSONLogger();
tsJson.log('Payment processed');
// {"message":"Payment processed","level":"info","timestamp":"2026-04-22T10:00:00.000Z"}
The hierarchy works until requirements combine features. At that point, you either create a subclass for every combination (which scales as 2^n for n features), or you start duplicating logic across subclasses. Neither outcome is acceptable.
The Composition Version (After)
The refactoring separates two concerns: what the logger does (writes output) from how it transforms messages before writing. Transformations become independent components. The logger holds a list of them and runs the message through each in sequence.
// AFTER: Composition-based Logger
// Formatters are independent components — mix and match freely.
// Component 1: Adds a timestamp prefix to any message
class TimestampFormatter {
format(message) {
return `[${new Date().toISOString()}] ${message}`;
}
}
// Component 2: Wraps any message in a JSON structure
class JSONFormatter {
format(message) {
return JSON.stringify({ message, level: 'info' });
}
}
// Component 3: Prefixes with a severity tag
class SeverityFormatter {
constructor(level = 'INFO') {
this.level = level;
}
format(message) {
return `[${this.level}] ${message}`;
}
}
// Component 4: Writes output to a destination
// In real code, 'destination' could be a file stream, HTTP client, etc.
class ConsoleOutput {
write(message) {
console.log(message);
}
}
class FileOutput {
constructor(filename) {
this.filename = filename;
}
write(message) {
// Simulated — real code would use fs.appendFileSync or a stream
console.log(`[FILE: ${this.filename}] ${message}`);
}
}
// The Logger class itself — it HAS formatters and an output, not IS-A anything
class Logger {
constructor({ formatters = [], output = new ConsoleOutput() } = {}) {
// Logger holds references to its components
// It does not extend them — it owns them
this.formatters = formatters;
this.output = output;
}
log(message) {
// Run the message through each formatter in order
// Each formatter is a black box — Logger does not care about internals
const formatted = this.formatters.reduce(
(msg, formatter) => formatter.format(msg),
message
);
this.output.write(formatted);
}
}
// Basic logger — no formatters, console output
const basic = new Logger();
basic.log('Server started');
// Server started
// Timestamp only
const tsLogger = new Logger({
formatters: [new TimestampFormatter()],
});
tsLogger.log('Request received');
// [2026-04-22T10:00:00.000Z] Request received
// JSON only
const jsonLogger = new Logger({
formatters: [new JSONFormatter()],
});
jsonLogger.log('User logged in');
// {"message":"User logged in","level":"info"}
// Timestamp + JSON — no new class needed, just compose
const tsJsonLogger = new Logger({
formatters: [new TimestampFormatter(), new JSONFormatter()],
});
tsJsonLogger.log('Payment processed');
// {"message":"[2026-04-22T10:00:00.000Z] Payment processed","level":"info"}
// Severity + Timestamp + JSON + File output — still no new class
const fullLogger = new Logger({
formatters: [
new SeverityFormatter('ERROR'),
new TimestampFormatter(),
new JSONFormatter(),
],
output: new FileOutput('app.log'),
});
fullLogger.log('Database connection failed');
// [FILE: app.log] {"message":"[ERROR] [2026-04-22T...] Database connection failed","level":"info"}
Every new combination of features is handled by passing a different array of components. No new class is needed. Each component can be tested in isolation. Swapping ConsoleOutput for FileOutput does not require touching Logger at all. Adding a new formatter (say, RedactPIIFormatter) requires writing one new class, then passing it in wherever needed.
The before version had 4 classes for 3-4 feature combinations, and was already breaking down. The after version has 6 small components, a simple Logger class, and can handle any combination of features without adding a single new class.
[IMAGE: Side-by-side comparison diagram. Left: inheritance tree with branching subclasses labeled "n combinations = n classes", nodes in neon orange. Right: Logger box with arrows pointing in from separate component boxes (TimestampFormatter, JSONFormatter, SeverityFormatter, ConsoleOutput), all in neon green. Caption: "Composition scales linearly. Inheritance scales exponentially."]
When Inheritance IS Still the Right Choice
Composition is the default, but it is not the only answer. Inheritance is the right tool when the "is-a" relationship is genuine, stable, and the Liskov Substitution Principle holds cleanly.
Use inheritance when:
The child is a true specialization of the parent. Dog extends Animal. Circle extends Shape. SavingsAccount extends BankAccount. The child fully satisfies every behavior the parent promises. Any code that uses a Shape works correctly when handed a Circle. That is LSP. When LSP holds, inheritance is expressing a real semantic truth about the domain, not a code-reuse shortcut.
The hierarchy is shallow (one or two levels). Deep hierarchies magnify the fragile base class problem. A single level of extension — a concrete class extending an abstract base — is often clean and well-controlled. Three or four levels of extension is where the brittleness compounds quickly.
The base class is designed and documented for extension. A base class that communicates its invariants, documents which methods are safe to override, and is explicitly built as an extension point is different from a base class that happened to have a useful method. The former is safe to extend. The latter is a trap.
You are implementing a framework extension point. Most UI frameworks and testing frameworks expect you to extend base classes. React class components extend React.Component. Jest's expect matchers follow extension contracts. These hierarchies are intentional and their base classes are stable. Extending them is appropriate.
// Inheritance that makes sense: genuine "is-a", shallow, LSP holds
class Shape {
constructor(color) {
this.color = color;
}
// All shapes can describe themselves
describe() {
return `A ${this.color} ${this.constructor.name} with area ${this.area().toFixed(2)}`;
}
// Abstract method — subclasses must implement this
area() {
throw new Error(`${this.constructor.name} must implement area()`);
}
}
// Circle IS-A Shape — the test passes completely
// A Circle satisfies every contract Shape promises
class Circle extends Shape {
constructor(color, radius) {
super(color);
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
}
// Rectangle IS-A Shape — same argument holds
class Rectangle extends Shape {
constructor(color, width, height) {
super(color);
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
// LSP holds: every function that accepts a Shape works with Circle and Rectangle
function printShapeInfo(shape) {
console.log(shape.describe());
}
printShapeInfo(new Circle('red', 5));
// A red Circle with area 78.54
printShapeInfo(new Rectangle('blue', 4, 6));
// A blue Rectangle with area 24.00
This is inheritance used correctly. Circle and Rectangle are genuine specializations of Shape. They add no methods that break the Shape contract. They can be substituted anywhere a Shape is expected. The hierarchy is two levels deep and unlikely to grow further.
Common Mistakes
-
Extending a class just to reuse one method. If you extend a parent only to inherit a single utility method, you've created a false "is-a" relationship. Extract that method into a separate utility function or a shared component and use it by reference instead. Inheritance for code reuse alone always creates hidden coupling.
-
Confusing "can do" with "is a." A
Birdcan fly. APlanecan fly. That does not meanPlane extends Bird. Shared capability is not shared identity. Model the shared capability as a component (FlyingEngine) or a mixin, not as a common parent class. -
Building hierarchies more than two levels deep. Every level of inheritance adds a layer of coupling and a new source of fragile base class risk. Anything deeper than grandparent-parent-child should trigger a serious review. The need for a third level of inheritance is usually a signal that composition would have been the correct starting point.
-
Calling composition "always better" in an interview. This is the overreaction to the GoF principle. Interviewers expect nuance. "Favor composition" is a default, not an absolute rule. Saying inheritance is always wrong signals that you're parroting advice without understanding it. Be specific: composition is better when features combine, when hierarchies would grow exponentially, or when the "is-a" test fails.
-
Mixing the pattern mid-design. Some developers start with composition, then add a
class X extends ComposedThingon top. The result combines the inflexibility of inheritance with the complexity of composition. Pick one structural approach per class hierarchy and stay consistent.
Interview Questions
Q: What does "favor composition over inheritance" mean, and where does it come from?
It comes from the Gang of Four's Design Patterns (1994). The principle says that when building flexible, reusable software, composing objects from smaller components is generally safer than extending class hierarchies. Inheritance is "white-box" reuse: subclasses see and depend on parent internals. Composition is "black-box" reuse: the composing class only knows the component's interface, not its implementation. The coupling is looser, the design is more flexible, and changes to a component don't silently break the holder.
Q: What is the fragile base class problem? Give a concrete example.
The fragile base class problem occurs when a change to a base class breaks a subclass that appeared unrelated to that change. For example: a
Loggerbase class has alog()method that writes to console. ATimestampLoggersubclass overrideslog()and callssuper.log(). The base class author later refactorslog()to batch writes internally. The subclass relied on immediate writes. The subclass breaks, even though nobody changed it. The coupling was invisible. Composition avoids this: a composed component change only affects the holder if the component's public interface changes.
Q: How do you decide between composition and inheritance for a new design?
Apply two tests. First, the "is-a" test: does the child class fully satisfy every behavior the parent promises? If yes, inheritance may be appropriate. Second, the Liskov Substitution Principle: can the child be substituted anywhere the parent is expected without changing the correctness of the program? If both tests pass and the hierarchy stays shallow (one or two levels), inheritance is a reasonable choice. If the relationship is really "has-a" (the child needs a capability, not a new identity), or if features would require exponentially many subclasses, use composition.
Q: How would you refactor a class that extends another class just to reuse a method?
Extract the shared method into a standalone function or a utility class. In the original class, hold a reference to the utility (composition) and call it directly. Remove the
extends. This breaks the false "is-a" relationship, eliminates the coupling to the base class internals, and makes the dependency explicit. The class no longer silently inherits everything else on the parent, which reduces the surface area for future bugs.
Q: Can you use both composition and inheritance in the same system?
Yes, and most real systems do. Inheritance is appropriate for genuine "is-a" relationships with stable, shallow hierarchies. Composition is appropriate for capabilities that combine flexibly. A common pattern: an abstract base class defines the contract (inheritance, one level), and the concrete implementations hold references to strategy or component objects (composition). This is the basis of several GoF patterns including Strategy, Decorator, and Template Method.
Quick Reference Cheat Sheet
COMPOSITION VS INHERITANCE — DECISION GUIDE
=============================================
THE CORE QUESTION
------------------
Ask: "Is the child class an [Parent]?" (Is-a test)
"Does the child satisfy ALL parent contracts?" (LSP test)
Both pass, shallow hierarchy -> Inheritance is fine
Either fails, OR features combine exponentially -> Composition
IS-A vs HAS-A
--------------
Is-a (use inheritance):
Dog extends Animal (Dog IS-A Animal)
Circle extends Shape (Circle IS-A Shape)
SavingsAccount extends BankAccount
Has-a (use composition):
Car HAS-A Engine (not: ElectricCar extends Car extends Vehicle)
Logger HAS-A Formatter (not: TimestampJSONLogger extends JSONLogger)
User HAS-A PermissionSet (not: AdminUser extends User for permissions)
FRAGILE BASE CLASS PROBLEM
---------------------------
Root cause: subclass silently depends on base class internals
Trigger: base class internal change, subclass not touched, subclass breaks
Prevention: keep hierarchies shallow, document extension points,
prefer composition when features need to combine
COMPOSITION PATTERN (SKELETON)
--------------------------------
class Logger {
constructor({ formatters = [], output }) {
this.formatters = formatters; // HAS-A, not IS-A
this.output = output;
}
log(message) {
const result = this.formatters.reduce(
(msg, f) => f.format(msg), message
);
this.output.write(result);
}
}
// Mix features without new classes:
new Logger({ formatters: [new TimestampFormatter(), new JSONFormatter()] })
INHERITANCE PATTERN (WHEN IT'S RIGHT)
---------------------------------------
class Shape { area() { ... } describe() { ... } }
class Circle extends Shape { area() { return Math.PI * r * r; } }
class Rectangle extends Shape { area() { return w * h; } }
// Circle IS-A Shape. LSP holds. Two levels deep. Stable.
WHEN INHERITANCE IS RIGHT
--------------------------
- Clear "is-a" + LSP holds
- Hierarchy is 1-2 levels deep
- Base class is designed for extension (abstract base)
- Framework extension point (React.Component, etc.)
WHEN COMPOSITION IS RIGHT
--------------------------
- Features need to combine (n features = n^2 subclasses problem)
- Relationship is "has-a" or "uses-a"
- You want to swap implementations at runtime
- Hierarchy would grow beyond 2 levels
GoF PRINCIPLE (1994)
---------------------
"Favor object composition over class inheritance."
Key word: "favor" — not "always." Use judgment.
RELATED DESIGN PATTERNS THAT USE COMPOSITION
----------------------------------------------
Strategy — swap behavior via a held strategy object
Decorator — wrap an object to add behavior without subclassing
Template Method — abstract base defines flow, composed parts fill in steps
Previous: Lesson 4.5 — The
superKeyword -> Next: Lesson 5.1 — Polymorphism ->
This is Lesson 4.6 of the OOP Interview Prep Course — 8 chapters, 41 lessons.