Design Principle
Composition Over Inheritance
Model behaviour with combinable units so change remains explicit and testable.
Inheritance shares behaviour by sharing ancestry. Composition shares behaviour by wiring objects together. Choose composition when you value clarity over hierarchy gymnastics.
1. Keep behaviours granular
Break features into capability modules. Compose them where you need them.
1type Policy = {2before?(input: any): Promise<void> | void;3after?(result: any): Promise<void> | void;4};56const withAudit = (policy: Policy): Policy => ({7before: async (input) => {8 console.log("[audit] input", input);9 await policy.before?.(input);10},11after: async (result) => {12 await policy.after?.(result);13 console.log("[audit] result", result);14},15});1617const withRetry = (retries: number, policy: Policy): Policy => ({18async before(input) {19 let attempts = 0;20 while (attempts++ < retries) {21 try {22 await policy.before?.(input);23 return;24 } catch (error) {25 if (attempts >= retries) throw error;26 }27 }28},29after: policy.after,30});3132const basePolicy: Policy = {};33const policy = withAudit(withRetry(3, basePolicy));
Result: Capabilities stay opt-in. No fragile base class required.
2. Treat UI as composition-friendly
React hooks and render props exemplify composition. They expose building blocks without enforcing ancestry.
1// Hooks compose behaviour without hierarchy.2function usePolling<T>(fetcher: () => Promise<T>, interval = 5000) {3const [data, setData] = useState<T | null>(null);4useEffect(() => {5 let cancelled = false;6 async function poll() {7 const result = await fetcher();8 if (!cancelled) setData(result);9 }10 poll();11 const id = setInterval(poll, interval);12 return () => {13 cancelled = true;14 clearInterval(id);15 };16}, [fetcher, interval]);17return data;18}1920function StatusBadge() {21const status = usePolling(() => fetch("/api/status").then((res) => res.json()));22return <Badge status={status} />;23}
Result: Any component can reuse usePolling without contorting inheritance chains.
3. Model cross-cutting concerns with decorators
Decorators (or higher-order functions) express orthogonal capabilities neatly.
1type Command = (...args: any[]) => Promise<void>;23const withLock =4(lockId: string, command: Command): Command =>5async (...args) => {6 const lock = await acquireLock(lockId);7 try {8 await command(...args);9 } finally {10 await lock.release();11 }12};1314const withMetrics =15(metric: string, command: Command): Command =>16async (...args) => {17 const started = performance.now();18 await command(...args);19 record(metric, performance.now() - started);20};2122const deploy = async () => {/* ... */};2324export const deployWithGuardrails = withMetrics(25"deploy.duration",26withLock("deploy", deploy),27);
Result: You can assemble features like Lego bricks, detaching capability decisions from inheritance diagrams.
Checklist
- Does extending a base class force you to inherit methods you don’t need? Switch to composition.
- Do you need multiple versions of a behaviour? Compose them around the shared core.
- Are tests struggling with deep hierarchies? Flatten by extracting mixins or helpers.
Reflection: Diagram a core service and its behaviours. Which responsibilities could be optional modules? Refactor one to use composition and measure how tests simplify.
Takeaway: Composition keeps change local. Your system evolves by snapping pieces together, not by balancing a brittle family tree.
Interview Questions
Beginner
Q: What is the difference between composition and inheritance? When should you prefer composition?
A:
Inheritance: Shares behavior by sharing ancestry (class extends another class). Child classes inherit all methods from parent.
Composition: Shares behavior by wiring objects together (object contains other objects). You combine capabilities explicitly.
Prefer composition when:
- You need multiple behaviors (can't inherit from multiple classes in most languages)
- Behaviors are optional (don't want to inherit everything)
- You want to change behavior at runtime
- Inheritance would create a deep, fragile hierarchy
Example: Instead of AdminUser extends User extends BaseEntity, compose: User contains Permissions, AuditLogger, EmailNotifier as separate objects.
Intermediate
Q: Refactor this inheritance hierarchy to use composition: Animal → Dog extends Animal → ServiceDog extends Dog. The classes have methods like eat(), bark(), fetch(), performTask().
A:
Problem with inheritance: ServiceDog inherits everything from Dog, even if it doesn't need it. Adding new animal types creates deep hierarchies.
Composition approach:
// Define behaviors as composable units
type EatingBehavior = { eat(): void };
type BarkingBehavior = { bark(): void };
type FetchingBehavior = { fetch(): void };
type TaskBehavior = { performTask(): void };
// Implement behaviors
const standardEat: EatingBehavior = { eat() { /* ... */ } };
const standardBark: BarkingBehavior = { bark() { /* ... */ } };
const standardFetch: FetchingBehavior = { fetch() { /* ... */ } };
const serviceTask: TaskBehavior = { performTask() { /* ... */ } };
// Compose animals from behaviors
class Dog {
constructor(
private eating: EatingBehavior = standardEat,
private barking: BarkingBehavior = standardBark,
private fetching: FetchingBehavior = standardFetch
) {}
eat() { this.eating.eat(); }
bark() { this.barking.bark(); }
fetch() { this.fetching.fetch(); }
}
class ServiceDog extends Dog {
constructor(
eating: EatingBehavior,
barking: BarkingBehavior,
fetching: FetchingBehavior,
private task: TaskBehavior = serviceTask
) {
super(eating, barking, fetching);
}
performTask() { this.task.performTask(); }
}
// Or even better - pure composition, no inheritance
class ServiceDog {
constructor(
private dog: Dog,
private task: TaskBehavior = serviceTask
) {}
eat() { this.dog.eat(); }
bark() { this.dog.bark(); }
fetch() { this.dog.fetch(); }
performTask() { this.task.performTask(); }
}
Benefits: Can swap behaviors (quiet dog, fetch-disabled dog), test behaviors independently, add new behaviors without modifying base classes.
Senior
Q: Design a plugin system for a code editor (like VS Code extensions). How would you use composition to allow plugins to add features (syntax highlighting, autocomplete, linting, formatting) without modifying core editor code?
A:
Core editor (closed for modification):
interface Editor {
registerPlugin(plugin: Plugin): void;
getDocument(): Document;
insertText(text: string, position: Position): void;
}
// Plugin interface - composition point
interface Plugin {
name: string;
capabilities: PluginCapability[];
activate(editor: Editor): void;
deactivate(): void;
}
// Capabilities as composable units
type SyntaxHighlighter = (doc: Document) => Highlight[];
type Autocompleter = (doc: Document, position: Position) => Completion[];
type Linter = (doc: Document) => Diagnostic[];
type Formatter = (doc: Document) => Edit[];
interface PluginCapability {
type: 'syntax-highlight' | 'autocomplete' | 'lint' | 'format';
handler: SyntaxHighlighter | Autocompleter | Linter | Formatter;
}
Plugin implementation (composition):
class TypeScriptPlugin implements Plugin {
name = 'typescript';
capabilities: PluginCapability[] = [
{
type: 'syntax-highlight',
handler: this.highlightTypeScript.bind(this)
},
{
type: 'autocomplete',
handler: this.completeTypeScript.bind(this)
},
{
type: 'lint',
handler: this.lintTypeScript.bind(this)
}
];
// Compose behaviors
private highlightTypeScript(doc: Document): Highlight[] { /* ... */ }
private completeTypeScript(doc: Document, pos: Position): Completion[] { /* ... */ }
private lintTypeScript(doc: Document): Diagnostic[] { /* ... */ }
activate(editor: Editor) {
// Register capabilities with editor
this.capabilities.forEach(cap => editor.registerCapability(cap));
}
}
Editor extension points (Open/Closed Principle + Composition):
class CodeEditor implements Editor {
private highlighters: SyntaxHighlighter[] = [];
private completers: Autocompleter[] = [];
private linters: Linter[] = [];
registerCapability(cap: PluginCapability) {
switch(cap.type) {
case 'syntax-highlight':
this.highlighters.push(cap.handler as SyntaxHighlighter);
break;
case 'autocomplete':
this.completers.push(cap.handler as Autocompleter);
break;
// ... other capabilities
}
}
// Compose results from all plugins
getHighlights(doc: Document): Highlight[] {
return this.highlighters.flatMap(h => h(doc));
}
}
Benefits:
- Plugins compose capabilities (can have highlighting + linting + formatting)
- Editor doesn't know about specific languages
- New plugins don't modify core editor
- Can enable/disable capabilities per plugin
- Test each capability independently
Key insight: Composition allows the editor to be extended (new plugins) without modification (core editor unchanged). Each plugin is a composition of capabilities, not a monolithic extension.
Key Takeaways
- Keep behaviors granular - break features into capability modules, compose them where needed
- Composition shares behavior by wiring objects together - no fragile base classes required
- Treat UI as composition-friendly - React hooks and render props exemplify composition without hierarchy
- Model cross-cutting concerns with decorators - higher-order functions express orthogonal capabilities neatly
- Capabilities stay opt-in - no need to inherit everything, just compose what you need
- Checklist: Does extending force you to inherit unused methods? Do you need multiple behavior versions? Are tests struggling with deep hierarchies?
- Composition keeps change local - system evolves by snapping pieces together, not balancing a brittle family tree
- Prefer composition when behaviors are optional or need to change at runtime
Keep exploring
Principles work best in chorus. Pair this lesson with another concept and observe how your architecture conversations change.