Loading content...
We've established what delegation is, how it works mechanically, and when to prefer it over inheritance. Now we turn to the practical question: How do you implement delegation effectively in real production codebases?
Effective implementation goes beyond knowing the concept. It requires understanding idioms, recognizing patterns, avoiding common pitfalls, and organizing code for clarity and maintainability. This page provides the practical guidance you need to implement delegation like an experienced architect.
We'll examine concrete implementation patterns, discuss code organization, explore common mistakes, and provide a checklist for delegation done right.
By the end of this page, you will know how to structure delegation in your code, follow established patterns for common scenarios, avoid typical mistakes, and apply a practical checklist for implementing delegation correctly. You'll be ready to apply delegation confidently in production systems.
Most delegation follows a standard structure. Understanding this template helps you implement delegation consistently:
The Three Components:
Interface (Protocol) — Defines what can be delegated. This is the contract between delegator and delegate.
Delegate Implementations — Concrete classes that implement the interface, providing different behaviors.
Delegator — The class that uses delegates, forwarding requests through the interface.
Let's see this structure in a complete example:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
// ============================================// COMPONENT 1: THE INTERFACE (PROTOCOL)// ============================================// Defines the contract for delegation// Keep it focused—one responsibility per interface interface NotificationSender { /** * Sends a notification to the specified recipient. * @param recipient - The notification target * @param message - The notification content * @returns true if sent successfully */ send(recipient: string, message: string): Promise<boolean>;} // ============================================// COMPONENT 2: DELEGATE IMPLEMENTATIONS// ============================================// Multiple implementations of the same interface// Each encapsulates a different behavior class EmailNotificationSender implements NotificationSender { constructor(private emailClient: EmailClient) {} async send(recipient: string, message: string): Promise<boolean> { try { await this.emailClient.sendEmail({ to: recipient, subject: 'Notification', body: message }); return true; } catch (error) { console.error('Email send failed:', error); return false; } }} class SMSNotificationSender implements NotificationSender { constructor(private smsGateway: SMSGateway) {} async send(recipient: string, message: string): Promise<boolean> { try { await this.smsGateway.sendSMS(recipient, message); return true; } catch (error) { console.error('SMS send failed:', error); return false; } }} class SlackNotificationSender implements NotificationSender { constructor(private slackClient: SlackClient) {} async send(recipient: string, message: string): Promise<boolean> { try { await this.slackClient.postMessage(recipient, message); return true; } catch (error) { console.error('Slack send failed:', error); return false; } }} // ============================================// COMPONENT 3: THE DELEGATOR// ============================================// Uses the interface, not concrete implementations// Receives delegate via constructor injection class NotificationService { constructor(private sender: NotificationSender) {} async notifyUser(userId: string, event: UserEvent): Promise<void> { const user = await this.getUserDetails(userId); const message = this.formatMessage(event); // DELEGATION: Forward to the injected sender const success = await this.sender.send(user.contactInfo, message); if (!success) { await this.logFailure(userId, event); } } private async getUserDetails(userId: string): Promise<User> { // ... fetch user } private formatMessage(event: UserEvent): string { return `Event: ${event.type} - ${event.description}`; } private async logFailure(userId: string, event: UserEvent): Promise<void> { // ... log for retry }} // ============================================// USAGE: WIRING IT TOGETHER// ============================================// Create delegates and inject into delegator // Productionconst emailSender = new EmailNotificationSender(new RealEmailClient());const notificationService = new NotificationService(emailSender); // Or use SMSconst smsSender = new SMSNotificationSender(new TwilioGateway());const smsNotificationService = new NotificationService(smsSender); // Testingconst mockSender: NotificationSender = { send: jest.fn() };const testService = new NotificationService(mockSender);Interface defines WHAT. Delegates define HOW (various implementations). Delegator uses WHICH (injected at construction). This separation is the heart of effective delegation.
Delegation and dependency injection (DI) are natural partners. DI provides the mechanism for supplying delegates to delegators. Let's examine the patterns:
Pattern 1: Constructor Injection (Preferred)
Provide all required delegates through the constructor:
class OrderService {
constructor(
private paymentProcessor: PaymentProcessor,
private inventoryManager: InventoryManager,
private notificationService: NotificationService
) {}
}
Advantages:
Pattern 2: Property/Setter Injection
For optional dependencies or when runtime changes are needed:
class ReportGenerator {
private formatter?: ReportFormatter;
setFormatter(formatter: ReportFormatter): void {
this.formatter = formatter;
}
generate(data: ReportData): Report {
const formatter = this.formatter ?? new DefaultFormatter();
return formatter.format(data);
}
}
Use when:
| Pattern | Use Case | Immutability | Testability |
|---|---|---|---|
| Constructor | Required dependencies | Supported (readonly) | Excellent |
| Setter | Optional dependencies | Not supported | Good |
| Method | Per-call variation | N/A (transient) | Excellent |
| Factory | Lazy/conditional creation | Depends | Good (inject factory) |
Pattern 3: Method Injection
Pass the delegate as a parameter to specific methods:
class DataExporter {
export(data: Data, formatter: DataFormatter): string {
return formatter.format(data);
}
}
// Different formatters for different calls
exporter.export(data, new JSONFormatter());
exporter.export(data, new CSVFormatter());
Use when:
Pattern 4: Container/Framework Injection
Let a DI container wire dependencies:
// Definition (e.g., in a module)
@Injectable()
class OrderService {
constructor(
@Inject(PAYMENT_PROCESSOR) private paymentProcessor: PaymentProcessor,
@Inject(INVENTORY_MANAGER) private inventoryManager: InventoryManager
) {}
}
// Registration
container.register(PAYMENT_PROCESSOR, StripePaymentProcessor);
container.register(INVENTORY_MANAGER, WarehouseInventoryManager);
// Resolution
const orderService = container.resolve(OrderService);
Advantages:
You don't need a DI framework to use dependency injection. Manual "poor man's DI" (just passing dependencies to constructors) works fine for smaller codebases. DI containers help manage complexity at scale, but the principle of injection applies regardless of tooling.
Sometimes you need to delegate to multiple objects of the same type, or chain delegates together. Here are patterns for complex delegation scenarios:
Pattern 1: Composite Delegate (Broadcast)
Delegate to multiple implementations, combining their results:
interface EventListener {
onEvent(event: Event): void;
}
class CompositeEventListener implements EventListener {
private listeners: EventListener[] = [];
addListener(listener: EventListener): void {
this.listeners.push(listener);
}
onEvent(event: Event): void {
// Delegate to ALL listeners
for (const listener of this.listeners) {
listener.onEvent(event);
}
}
}
The composite itself implements the interface, so it can be used wherever a single delegate is expected. Clients don't know they're talking to a composite.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// Pattern 2: Chain of Responsibility// Delegate until one handler succeeds interface RequestHandler { handle(request: Request): Response | null;} class HandlerChain implements RequestHandler { private handlers: RequestHandler[] = []; addHandler(handler: RequestHandler): void { this.handlers.push(handler); } handle(request: Request): Response | null { for (const handler of this.handlers) { const response = handler.handle(request); if (response !== null) { return response; // First handler that responds wins } } return null; // No handler could process }} // Concrete handlersclass AuthHandler implements RequestHandler { handle(request: Request): Response | null { if (request.path.startsWith('/auth')) { return this.processAuth(request); } return null; // Pass to next handler }} class APIHandler implements RequestHandler { handle(request: Request): Response | null { if (request.path.startsWith('/api')) { return this.processAPI(request); } return null; }} // Usageconst chain = new HandlerChain();chain.addHandler(new AuthHandler());chain.addHandler(new APIHandler());chain.addHandler(new StaticFileHandler()); // Fallback const response = chain.handle(incomingRequest); // Pattern 3: Decorator Chain// Each delegate wraps the next, adding behavior interface DataProcessor { process(data: string): string;} class BaseProcessor implements DataProcessor { process(data: string): string { return data; // Identity—just return data }} class ValidationDecorator implements DataProcessor { constructor(private wrapped: DataProcessor) {} process(data: string): string { if (!data || data.length === 0) { throw new Error('Empty data'); } return this.wrapped.process(data); }} class LoggingDecorator implements DataProcessor { constructor(private wrapped: DataProcessor) {} process(data: string): string { console.log('Processing:', data.substring(0, 50)); const result = this.wrapped.process(data); console.log('Result:', result.substring(0, 50)); return result; }} class CompressionDecorator implements DataProcessor { constructor(private wrapped: DataProcessor) {} process(data: string): string { const processed = this.wrapped.process(data); return compress(processed); }} // Build a chain of decoratorsconst processor: DataProcessor = new LoggingDecorator( new ValidationDecorator( new CompressionDecorator( new BaseProcessor() ) ) ); // Each layer delegates to the next while adding its behaviorconst result = processor.process(inputData);Pattern 3: Strategy Selection
Choose among delegates based on context:
class PaymentProcessor {
private strategies: Map<PaymentMethod, PaymentStrategy> = new Map();
registerStrategy(method: PaymentMethod, strategy: PaymentStrategy): void {
this.strategies.set(method, strategy);
}
process(payment: Payment): Result {
const strategy = this.strategies.get(payment.method);
if (!strategy) {
throw new Error(`No strategy for ${payment.method}`);
}
return strategy.process(payment); // Delegate to selected strategy
}
}
// Register strategies
processor.registerStrategy(PaymentMethod.CARD, new CardStrategy());
processor.registerStrategy(PaymentMethod.BANK, new BankTransferStrategy());
processor.registerStrategy(PaymentMethod.CRYPTO, new CryptoStrategy());
These patterns show how delegation scales. Composites, chains, and decorators can themselves be delegates. You can build arbitrarily complex behavior from simple, focused components—each following the same delegation principles.
When delegates are optional, you often need to handle the "no delegate" case. The Null Object pattern provides an elegant solution by implementing a delegate that does nothing—avoiding null checks throughout your code.
The Problem:
class Logger {
private formatter?: Formatter;
log(message: string): void {
// Null check everywhere!
const formatted = this.formatter
? this.formatter.format(message)
: message;
console.log(formatted);
}
}
The Solution: Null Object
interface Formatter {
format(message: string): string;
}
// Null Object—does nothing, returns input unchanged
class NullFormatter implements Formatter {
format(message: string): string {
return message; // Identity—no formatting
}
}
class Logger {
private formatter: Formatter; // Never null!
constructor(formatter: Formatter = new NullFormatter()) {
this.formatter = formatter;
}
log(message: string): void {
// No null check needed—delegation just works
const formatted = this.formatter.format(message);
console.log(formatted);
}
}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// More Null Object Examples // Null Logger—silently ignores all log callsinterface Logger { log(level: LogLevel, message: string): void; error(message: string, error?: Error): void;} class NullLogger implements Logger { log(level: LogLevel, message: string): void { // Do nothing } error(message: string, error?: Error): void { // Do nothing }} // Use when logging should be disabledconst silentService = new DataService(new NullLogger()); // Null Cache—always misses, never storesinterface Cache<T> { get(key: string): T | undefined; set(key: string, value: T): void;} class NullCache<T> implements Cache<T> { get(key: string): T | undefined { return undefined; // Always cache miss } set(key: string, value: T): void { // Do nothing—don't store }} // Use when caching should be disabledconst uncachedService = new UserService(new NullCache<User>()); // Null Validator—always passesinterface Validator<T> { validate(value: T): ValidationResult;} class NullValidator<T> implements Validator<T> { validate(value: T): ValidationResult { return { valid: true, errors: [] }; // Always valid }} // Use when validation should be skippedconst trustingService = new FormHandler(new NullValidator<FormData>()); // Null Event Publisher—events disappearinterface EventPublisher { publish(event: Event): Promise<void>;} class NullEventPublisher implements EventPublisher { async publish(event: Event): Promise<void> { // Event is silently dropped }}if (delegate !== null) scattered through code.Since null objects are stateless, you can make them singletons to avoid creating redundant instances. Export a shared instance: export const NULL_LOGGER = new NullLogger();
Even with good intentions, delegation can be implemented poorly. Here are common mistakes and how to avoid them:
Mistake 1: Internal Instantiation of Delegates
// BAD: Tight coupling, untestable
class OrderService {
private paymentGateway = new StripePaymentGateway(); // Hardcoded!
}
// GOOD: Injected dependency
class OrderService {
constructor(private paymentGateway: PaymentGateway) {} // Interface
}
Why it's bad: You can't substitute different implementations, can't mock for testing, and are tightly coupled to a specific concrete class.
Mistake 2: Depending on Concrete Classes Instead of Interfaces
// BAD: Depends on concrete class
class ReportGenerator {
constructor(private formatter: PDFFormatter) {} // Concrete!
}
// GOOD: Depends on interface
class ReportGenerator {
constructor(private formatter: ReportFormatter) {} // Interface
}
Why it's bad: Defeats the purpose of delegation—you're still coupled to a specific implementation.
new ConcreteDelegate() inside the delegator.if (delegate !== null).Mistake 3: Leaking Delegate Implementation Details
// BAD: Exposes how delegate works internally
class DocumentService {
getDocument(id: string): Document {
const rawData = this.storage.getRawBytes(id); // Storage detail
const checksum = this.storage.getChecksum(id); // Storage detail
// ... process using storage internals
}
}
// GOOD: Uses delegate at the right abstraction level
class DocumentService {
getDocument(id: string): Document {
return this.storage.retrieveDocument(id); // Appropriate abstraction
}
}
Mistake 4: Exposing Internal Delegates
// BAD: Breaks encapsulation
class Car {
getEngine(): Engine {
return this.engine; // Exposes internal!
}
}
// Caller can now: car.getEngine().overheat(); // Bypasses Car!
// GOOD: Expose capabilities, not internals
class Car {
startEngine(): void {
this.engine.start();
}
getEngineStatus(): EngineStatus {
return this.engine.getStatus(); // Read-only info, not the engine itself
}
}
If you find yourself adding getDelegate() methods, pause and ask why the caller needs direct access. Usually, the delegator should expose methods that use the delegate, not the delegate itself. "Tell, Don't Ask" applies here.
How you organize delegation-heavy code affects maintainability. Here are proven organizational patterns:
Pattern 1: Interfaces Near Consumers
Place interfaces close to where they're used (in the consumer's module), not with implementations:
src/
orders/
OrderService.ts # Delegator
PaymentGateway.ts # Interface (defined here, used here)
payments/
StripeGateway.ts # Implementation of PaymentGateway
PayPalGateway.ts # Another implementation
Why: The interface is part of the Order module's contract. Payment implementations depend on the interface, not the other way around. This follows the Dependency Inversion Principle.
Pattern 2: Separate Interface Packages
For shared interfaces used across multiple modules:
src/
interfaces/
PaymentGateway.ts
NotificationSender.ts
orders/
OrderService.ts
payments/
StripeGateway.ts
notifications/
EmailSender.ts
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// Pattern 3: Feature-Based Organization// Group by feature, with clear internal structure // src/orders/// ├── index.ts # Public exports// ├── OrderService.ts # Main service (delegator)// ├── types.ts # Interfaces this module defines// │ └── PaymentProcessor.ts # Interface for payment delegation// │ └── InventoryChecker.ts # Interface for inventory delegation// └── testing/// └── mocks.ts # Mock implementations for testing // src/orders/types.tsexport interface PaymentProcessor { process(amount: Money): Promise<PaymentResult>;} export interface InventoryChecker { check(items: OrderItem[]): Promise<InventoryStatus>;} // src/orders/OrderService.tsimport { PaymentProcessor, InventoryChecker } from './types'; export class OrderService { constructor( private paymentProcessor: PaymentProcessor, private inventoryChecker: InventoryChecker ) {} // ... implementation} // src/orders/testing/mocks.tsimport { PaymentProcessor, InventoryChecker } from '../types'; export class MockPaymentProcessor implements PaymentProcessor { public lastAmount?: Money; async process(amount: Money): Promise<PaymentResult> { this.lastAmount = amount; return { success: true }; }} // src/orders/index.tsexport { OrderService } from './OrderService';export type { PaymentProcessor, InventoryChecker } from './types';// Don't export mocks from main index—separate test imports // Pattern 4: Composition Root// Central place where all delegation wiring happens // src/composition-root.tsimport { OrderService } from './orders';import { StripePaymentProcessor } from './payments/stripe';import { WarehouseInventoryChecker } from './inventory/warehouse';import { EmailNotificationSender } from './notifications/email'; export function createOrderService(): OrderService { return new OrderService( new StripePaymentProcessor(config.stripe), new WarehouseInventoryChecker(config.warehouse) );} export function createNotificationService(): NotificationService { return new NotificationService( new EmailNotificationSender(config.email) );} // Entry point uses composition root// src/main.tsimport { createOrderService, createNotificationService } from './composition-root'; const orderService = createOrderService();const notificationService = createNotificationService();Pattern 4: Composition Root
Have a single location (the "composition root") where all delegation wiring happens:
new ConcreteDelegate() scattered throughout codebaseThis pattern is especially valuable in larger applications and aligns well with DI containers.
Interfaces are contracts. Changing them affects all implementations. Design interfaces carefully, keep them minimal, and version them if breaking changes are needed. Stable interfaces enable independent evolution of components.
Use this checklist when implementing delegation to ensure you're following best practices:
Before Implementation:
new ConcreteDelegate() in the delegatorBefore submitting code with new delegation, walk through this checklist. Catching issues before code review saves everyone time and results in better designs reaching production.
We've covered comprehensive guidance for implementing delegation effectively. Let's consolidate the key insights:
Module Complete
With this page, we've completed our exploration of the Delegation Pattern. You now understand:
Delegation, combined with composition, provides the flexibility that rigid inheritance hierarchies cannot match. As you apply these patterns, you'll find your designs becoming more adaptable, more testable, and more maintainable.
In the Next Module:
We'll examine When to Use Inheritance vs Composition—a decision framework that synthesizes everything we've learned about these related but distinct techniques, helping you make confident design decisions in any situation.
Congratulations! You've mastered the Delegation Pattern—from foundational concepts through practical implementation. Delegation is a cornerstone of flexible object-oriented design. Combined with the composition principles from earlier in this chapter, you now have the tools to build systems that adapt to changing requirements without the fragility of rigid inheritance hierarchies.