Loading content...
We've now explored both inheritance (in earlier chapters) and delegation (in this module). Both mechanisms allow objects to reuse behavior—to leverage existing implementations rather than writing everything from scratch. But they achieve this goal in fundamentally different ways, with profound implications for design flexibility, maintainability, and system evolution.
Inheritance says: "I am a specialized version of you. I inherit your behavior and override what I need to."
Delegation says: "I use your capabilities. I forward requests to you and compose your behavior with mine."
This page directly compares these two approaches, examining their fundamental differences, tradeoffs, and appropriate use cases. By the end, you'll have a clear framework for choosing between them.
By the end of this page, you will understand the fundamental differences between delegation and inheritance, the specific tradeoffs each approach makes, and a clear decision framework for choosing between them. You'll see why delegation often wins, but also understand when inheritance is genuinely appropriate.
Let's establish the core conceptual differences between delegation and inheritance. These aren't just implementation details—they reflect fundamentally different approaches to structuring object relationships.
Type Relationship vs Object Relationship
Inheritance creates a type relationship. A subclass IS a subtype of its parent. When you create Dog extends Animal, every Dog instance IS an Animal.
Delegation creates an object relationship. A delegator HAS a delegate. When you create Car with a Engine, the Car instance HAS an Engine instance, but is not an Engine.
Static vs Dynamic
Inheritance is static. The parent class is fixed at compile time. A Dog is forever an Animal; this cannot change at runtime.
Delegation can be dynamic. The delegate can be swapped at runtime. A Car can have its Engine replaced while the program executes.
Visibility
Inheritance provides visibility into parent internals. Subclasses can access protected members and override methods.
Delegation respects encapsulation. Delegators only see the delegate's public interface—no access to internals.
| Aspect | Inheritance | Delegation |
|---|---|---|
| Relationship Type | IS-A (type/subtype) | HAS-A (object/collaborator) |
| Binding Time | Compile-time (static) | Runtime (dynamic possible) |
| Coupling | Tight (to parent implementation) | Loose (to interface contract) |
| Encapsulation | Broken (access to protected members) | Preserved (only public interface) |
| Reuse Mechanism | Implicit inheritance of behavior | Explicit forwarding of requests |
| Flexibility | Fixed at design time | Configurable, even at runtime |
| Code Reuse Style | White-box (internals visible) | Black-box (internals hidden) |
Implicit vs Explicit Behavior
Inheritance makes behavior acquisition implicit. A subclass automatically has all parent methods. You don't see in the subclass code what methods are available—they come from the hierarchy.
Delegation makes behavior acquisition explicit. Every delegated method must be explicitly forwarded. You can see in the delegator's code exactly what capabilities it provides.
Change Propagation
Inheritance propagates parent changes to all descendants. If the parent changes, all subclasses are affected—for better or worse.
Delegation isolates changes behind interfaces. A delegate's internal changes don't affect delegators as long as the interface contract is maintained.
The fundamental difference in coupling is perhaps the most important distinction. Inheritance couples you to implementation; delegation couples you to interfaces. This single difference explains most of inheritance's problems and delegation's advantages.
Delegation provides several forms of flexibility that inheritance cannot match:
1. Runtime Behavior Changes
With delegation, behavior can be changed while the program is running:
class Duck {
constructor(private flyBehavior: FlyBehavior) {}
fly(): void {
this.flyBehavior.fly();
}
setFlyBehavior(behavior: FlyBehavior): void {
this.flyBehavior = behavior;
}
}
// Runtime change!
const duck = new Duck(new StandardFlight());
duck.fly(); // Uses StandardFlight
duck.setFlyBehavior(new RocketPoweredFlight());
duck.fly(); // Now uses RocketPoweredFlight!
With inheritance, you'd need to create a new RocketPoweredDuck subclass and recreate the object—losing all state in the process.
2. Mixing Behaviors from Multiple Sources
Delegation allows composing behaviors from multiple, unrelated sources:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// With delegation, compose any combination of behaviorsinterface Flyable { fly(): void;} interface Swimmable { swim(): void;} interface Walkable { walk(): void;} class Animal { constructor( private flyBehavior: Flyable | null, private swimBehavior: Swimmable | null, private walkBehavior: Walkable | null ) {} // Delegate to appropriate behavior fly(): void { if (this.flyBehavior) { this.flyBehavior.fly(); } else { console.log("I can't fly!"); } } swim(): void { if (this.swimBehavior) { this.swimBehavior.swim(); } else { console.log("I can't swim!"); } } walk(): void { if (this.walkBehavior) { this.walkBehavior.walk(); } else { console.log("I can't walk!"); } }} // Create any combination!const duck = new Animal( new WingFlapping(), new Paddling(), new Waddling()); const penguin = new Animal( null, // Can't fly new FastSwimming(), new Waddling()); const fish = new Animal( null, // Can't fly new FinPropulsion(), null // Can't walk);With inheritance, you'd need a complex hierarchy trying to represent all combinations (FlyingSwimmingAnimal, SwimmingWalkingAnimal, etc.), quickly leading to a combinatorial explosion.
3. Selective Implementation
Delegation lets you choose exactly what to expose:
class LimitedList<T> {
private items: T[] = [];
// Expose only what we want
add(item: T): void { this.items.push(item); }
get(index: number): T { return this.items[index]; }
// Intentionally NOT exposing: splice, shift, unshift, etc.
}
With inheritance, you inherit everything—even things you'd rather hide.
4. Independent Evolution
Delegates can evolve independently of delegators:
// Version 2 of formatter—internal changes only
class MarkdownFormatterV2 implements Formatter {
// Completely rewritten internals
// Interface unchanged—no delegator impact
}
Where inheritance creates hierarchies that grow exponentially to handle combinations, delegation enables linear growth. N behaviors require N behavior classes, and any object can mix any combination. This is why the Strategy pattern is so powerful.
We've discussed inheritance's problems in earlier chapters, but it's instructive to revisit them in direct comparison with delegation:
The Fragile Base Class Problem
Changes to a base class can break subclasses in unexpected ways, even when the public interface hasn't changed:
// Base class
class Collection {
add(item: Item): void {
this.items.push(item);
}
addAll(items: Item[]): void {
items.forEach(item => this.add(item)); // Calls add()
}
}
// Subclass
class CountingCollection extends Collection {
private count = 0;
add(item: Item): void {
this.count++;
super.add(item);
}
}
// Problem: addAll calls add, so count is incremented per item
// If base class changes addAll to not use add, behavior breaks!
Delegation avoids this because the delegator doesn't depend on how the delegate implements things internally—only on the interface contract.
The "Broken" Liskov Substitution Problem
Inheritance encourages creating subclasses that don't truly substitute for their parents:
class Rectangle {
setWidth(w: number): void { this.width = w; }
setHeight(h: number): void { this.height = h; }
}
class Square extends Rectangle {
// Must override to maintain invariant
setWidth(w: number): void {
this.width = w;
this.height = w; // Breaks Rectangle's contract!
}
}
Delegation doesn't create these traps because there's no subtype relationship to violate:
class Square {
private rectangle: Rectangle;
setSide(s: number): void {
this.rectangle.setWidth(s);
this.rectangle.setHeight(s);
}
// No pretense of being a Rectangle—no LSP concerns
}
Subclasses implicitly depend on undocumented aspects of parent behavior: when methods are called, in what order, what internal state they modify. These hidden contracts are the source of many inheritance bugs. Delegation's interface contracts are explicit and visible.
Despite delegation's advantages, inheritance remains the right choice in specific situations. Understanding these helps avoid dogmatic rejection of a useful tool.
1. True Is-A Relationships with Full Substitutability
When Liskov Substitution genuinely holds—when every subclass instance can substitute for the parent in all contexts without breaking assumptions—inheritance models the domain correctly:
// Genuine is-a: Every IOException IS-A Exception
class IOException extends Exception { }
// Genuine is-a: Every HttpServletRequest IS-A ServletRequest
class HttpServletRequest extends ServletRequest { }
2. Framework Extension Points Designed for Inheritance
Some frameworks are designed around inheritance, providing base classes with protected methods as extension hooks (Template Method pattern):
// Framework provides
abstract class TestCase {
protected setUp(): void { } // Override hook
protected tearDown(): void { } // Override hook
abstract runTest(): void;
}
// You extend
class MyTest extends TestCase {
protected setUp(): void {
this.db = createTestDatabase();
}
}
Fighting the framework's design rarely ends well. Accept inheritance where it's idiomatically expected.
| Scenario | Why Inheritance Works | Example |
|---|---|---|
| Exception hierarchies | True is-a with substitutability | IOException extends Exception |
| Framework extension | Designed for override hooks | extending JUnit TestCase |
| Immutable value types | No behavior to vary at runtime | Integer extends Number |
| Small, stable hierarchies | Low change probability | Color subclasses (immutable) |
| Template Method pattern | Specific design for inheritance | Abstract algorithm with hooks |
| Domain modeling is-a | Genuine type relationship | Manager extends Employee (careful!) |
3. Immutable Value Types
Inheritance works well for immutable values because there's no mutable state to corrupt and no behavior that needs runtime variation:
// Value types—immutable, no behavior to swap
class Money {
constructor(readonly amount: number, readonly currency: string) {}
}
class USDMoney extends Money {
constructor(amount: number) {
super(amount, 'USD');
}
}
4. Language/Platform Requirements
Some patterns require inheritance due to language mechanics:
5. Small, Closed, Stable Hierarchies
When you control all subclasses, the hierarchy is small, and requirements are stable, inheritance's downsides are mitigated:
// Closed enumeration—all subclasses known and controlled
abstract class HttpMethod {
abstract readonly name: string;
}
class GET extends HttpMethod { readonly name = 'GET'; }
class POST extends HttpMethod { readonly name = 'POST'; }
// ...all methods known upfront
Think of delegation as the default and inheritance as needing justification. Ask: "Why inheritance here?" If you can articulate a compelling reason (true is-a, framework design, immutable values), inheritance is fine. If the answer is "code reuse" or "seems simpler," try delegation first.
Let's examine the same problem solved both ways. This concrete comparison illuminates the practical differences.
The Problem: We need various types of loggers—file logger, database logger, console logger—that share some common functionality but differ in their output destination.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// INHERITANCE APPROACH abstract class Logger { protected logLevel: LogLevel = LogLevel.INFO; log(level: LogLevel, message: string): void { if (level >= this.logLevel) { const formatted = this.format(message); this.output(formatted); // Template method } } protected format(message: string): string { return `[${new Date().toISOString()}] ${message}`; } // Abstract method—subclasses must implement protected abstract output(message: string): void; setLogLevel(level: LogLevel): void { this.logLevel = level; }} class FileLogger extends Logger { constructor(private filePath: string) { super(); } protected output(message: string): void { fs.appendFileSync(this.filePath, message + '\n'); }} class ConsoleLogger extends Logger { protected output(message: string): void { console.log(message); }} class DatabaseLogger extends Logger { constructor(private db: Database) { super(); } protected output(message: string): void { this.db.insert('logs', { message, timestamp: new Date() }); }} // Usageconst logger: Logger = new FileLogger('/var/log/app.log');logger.log(LogLevel.INFO, 'Application started'); // Issues:// 1. Can't change where logs go at runtime// 2. Subclasses depend on Logger's format() implementation// 3. Adding a new formatting style requires modifying the hierarchy// 4. Hard to test FileLogger without actually writing files12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// DELEGATION APPROACH interface LogFormatter { format(message: string): string;} interface LogDestination { write(message: string): void;} // Formatters—reusable across any loggerclass TimestampFormatter implements LogFormatter { format(message: string): string { return `[${new Date().toISOString()}] ${message}`; }} class JsonFormatter implements LogFormatter { format(message: string): string { return JSON.stringify({ message, timestamp: new Date() }); }} // Destinations—reusable, mockableclass FileDestination implements LogDestination { constructor(private filePath: string) {} write(message: string): void { fs.appendFileSync(this.filePath, message + '\n'); }} class ConsoleDestination implements LogDestination { write(message: string): void { console.log(message); }} class DatabaseDestination implements LogDestination { constructor(private db: Database) {} write(message: string): void { this.db.insert('logs', { message, timestamp: new Date() }); }} // Logger uses DELEGATIONclass Logger { private logLevel: LogLevel = LogLevel.INFO; constructor( private formatter: LogFormatter, private destination: LogDestination ) {} log(level: LogLevel, message: string): void { if (level >= this.logLevel) { const formatted = this.formatter.format(message); this.destination.write(formatted); } } setLogLevel(level: LogLevel): void { this.logLevel = level; } // Can change at runtime! setFormatter(formatter: LogFormatter): void { this.formatter = formatter; } setDestination(destination: LogDestination): void { this.destination = destination; }} // Usage—any combination!const logger = new Logger( new TimestampFormatter(), new FileDestination('/var/log/app.log')); // Runtime reconfigurationlogger.setDestination(new ConsoleDestination());logger.setFormatter(new JsonFormatter()); // Easy testingconst mockDestination = { write: jest.fn() };const testLogger = new Logger(new TimestampFormatter(), mockDestination);testLogger.log(LogLevel.INFO, 'test');expect(mockDestination.write).toHaveBeenCalled();The delegation approach separates concerns (formatting vs destination), enables runtime changes, avoids combinatorial hierarchy explosion, and makes testing trivial. The initial code is slightly more verbose, but the design is far more flexible and maintainable.
Given everything we've discussed, here's a practical framework for choosing between delegation and inheritance:
Step 1: Ask "Is there a true IS-A relationship with behavioral substitutability?"
Not "could this be modeled as IS-A," but "IS this genuinely a subtype that can substitute in all contexts?"
Step 2: Ask "Might the behavior need to vary at runtime?"
If there's any chance you'll need different behavior without recreating objects, delegation is required.
Step 3: Ask "Is this a framework extension point designed for inheritance?"
If yes, follow the framework's idiom—inheritance is expected.
Step 4: Ask "Will there be multiple independent dimensions of variation?"
If you need to combine behaviors from multiple orthogonal sources, delegation is essential. Inheritance creates exponential hierarchies for combinations.
| Question | If Yes | If No |
|---|---|---|
| True IS-A with full LSP compliance? | Consider inheritance | Use delegation |
| Behavior might vary at runtime? | Must use delegation | Either could work |
| Multiple independent variations? | Must use delegation | Either could work |
| Framework designed for inheritance? | Use inheritance | Prefer delegation |
| Testing is important (always)? | Delegation much easier | N/A |
| Hierarchy is small and stable? | Inheritance acceptable | Prefer delegation |
The Heuristic Summary
Default to delegation. Use inheritance when:
In all other cases, delegation provides more flexibility with fewer risks.
The Cost Consideration
Delegation has some costs:
But these costs are typically minor compared to the benefits. The explicitness is often a feature, not a bug—dependencies are visible and testable.
Inheritance costs:
These costs compound over time as systems evolve.
If you're unsure whether to use inheritance or delegation, choose delegation. It's easier to change from delegation to inheritance later than the reverse. Delegation is the lower-risk default.
Inheritance and delegation aren't mutually exclusive. Many real-world designs use both strategically:
Pattern 1: Inherit Structure, Delegate Behavior
Use inheritance for a stable type hierarchy, but delegate specific behaviors:
// Inherit for type relationship
abstract class Shape {
abstract getArea(): number;
// Delegate rendering behavior
constructor(protected renderer: ShapeRenderer) {}
render(): void {
this.renderer.render(this);
}
}
class Circle extends Shape {
constructor(public radius: number, renderer: ShapeRenderer) {
super(renderer);
}
getArea(): number {
return Math.PI * this.radius * this.radius;
}
}
// Different renderers for same shapes
const circle1 = new Circle(5, new SVGRenderer());
const circle2 = new Circle(5, new CanvasRenderer());
The type hierarchy (Shape → Circle) is stable and represents genuine IS-A. The varying behavior (rendering) is delegated.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Pattern 2: Delegation with Shared Base Class interface PaymentProvider { charge(amount: number): Promise<ChargeResult>; refund(chargeId: string): Promise<RefundResult>;} // Shared base with common logicabstract class AbstractPaymentProvider implements PaymentProvider { protected abstract doCharge(amount: number): Promise<RawChargeResult>; protected abstract doRefund(chargeId: string): Promise<RawRefundResult>; // Common logic shared via inheritance async charge(amount: number): Promise<ChargeResult> { this.validateAmount(amount); const rawResult = await this.doCharge(amount); return this.mapToChargeResult(rawResult); } async refund(chargeId: string): Promise<RefundResult> { this.validateChargeId(chargeId); const rawResult = await this.doRefund(chargeId); return this.mapToRefundResult(rawResult); } private validateAmount(amount: number): void { if (amount <= 0) throw new Error('Invalid amount'); } private validateChargeId(id: string): void { if (!id) throw new Error('Invalid charge ID'); } // ... mapping logic} // Concrete implementations inherit shared logicclass StripeProvider extends AbstractPaymentProvider { protected async doCharge(amount: number): Promise<RawChargeResult> { // Stripe-specific API call } protected async doRefund(chargeId: string): Promise<RawRefundResult> { // Stripe-specific API call }} class PayPalProvider extends AbstractPaymentProvider { protected async doCharge(amount: number): Promise<RawChargeResult> { // PayPal-specific API call } protected async doRefund(chargeId: string): Promise<RawRefundResult> { // PayPal-specific API call }} // Delegator uses the interfaceclass PaymentService { constructor(private provider: PaymentProvider) {} async processPayment(order: Order): Promise<void> { await this.provider.charge(order.total); }} // Delegation for flexibility, inheritance for shared implementationconst stripeService = new PaymentService(new StripeProvider());const paypalService = new PaymentService(new PayPalProvider());Pattern 2: Abstract Class for Shared Code, Interface for Contract
Define the contract as an interface, provide an optional abstract base for shared implementation:
interface Repository<T> {
find(id: string): T | null;
save(entity: T): void;
}
abstract class AbstractRepository<T> implements Repository<T> {
// Shared logic that many repositories need
protected cache = new Map<string, T>();
find(id: string): T | null {
if (this.cache.has(id)) return this.cache.get(id)!;
const entity = this.doFind(id);
if (entity) this.cache.set(id, entity);
return entity;
}
protected abstract doFind(id: string): T | null;
// ...
}
// Option 1: Inherit shared logic
class SQLUserRepository extends AbstractRepository<User> {
protected doFind(id: string): User | null { ... }
}
// Option 2: Implement from scratch
class InMemoryUserRepository implements Repository<User> {
private data = new Map<string, User>();
find(id: string): User | null { return this.data.get(id) ?? null; }
save(user: User): void { this.data.set(user.id, user); }
}
Using an interface as the contract allows pure delegation while optionally offering inheritance for implementations that want shared logic.
The best designs often use inheritance for stable, closed hierarchies (exceptions, value types, framework hooks) while using delegation for variable, evolving behaviors. They're complementary tools—use each where it excels.
We've comprehensively compared delegation and inheritance. Let's consolidate the key insights:
What's Next:
Now that we've compared delegation with inheritance, the final page focuses on Implementing Delegation Effectively—practical patterns, code organization, and best practices for using delegation in production codebases.
You now have a comprehensive understanding of how delegation and inheritance compare—their fundamental differences, tradeoffs, appropriate use cases, and decision criteria. The principle "favor composition over inheritance" should now feel deeply understood rather than merely memorized. Next, we'll focus on implementing delegation effectively in practice.