Loading learning content...
In the previous page, we established what delegation is conceptually. Now we turn to the how: the actual mechanics of forwarding requests from a delegator to its contained objects.
Understanding these mechanics is essential because the way you structure delegation affects testability, flexibility, and maintainability. Poor delegation setups create hidden dependencies and testing nightmares. Well-designed delegation creates clean, decoupled systems that are a pleasure to work with.
This page examines the practical patterns for implementing effective delegation: how delegates are acquired, how requests are forwarded, and the idioms that make delegation work smoothly in real codebases.
By the end of this page, you will understand the patterns for acquiring delegates (constructor injection, setter injection, factory creation), the mechanics of request forwarding, and the design considerations that separate brittle delegation from flexible, testable designs.
The first question in delegation mechanics is: How does the delegator get a reference to its delegate? This seemingly simple question has profound implications for coupling, testability, and flexibility.
There are several approaches, each with distinct tradeoffs:
1. Constructor Injection (Preferred)
The delegate is provided when the delegator is constructed. This is generally the best approach for required dependencies:
class OrderProcessor {
constructor(
private paymentGateway: PaymentGateway,
private inventoryService: InventoryService
) {}
}
Advantages:
2. Setter Injection (For Optional/Changeable Delegates)
The delegate is set via a method after construction. Use for optional dependencies or when runtime swapping is needed:
class Printer {
private formatter?: DocumentFormatter;
setFormatter(formatter: DocumentFormatter): void {
this.formatter = formatter;
}
}
Advantages:
Disadvantages:
| Pattern | When to Use | Coupling Level | Testability |
|---|---|---|---|
| Constructor Injection | Required dependencies | Very Low | Excellent |
| Setter Injection | Optional/changeable deps | Low | Good |
| Factory/Provider | Lazy or contextual creation | Low | Good (factory is injected) |
| Service Locator | Legacy or framework constraints | Medium | Moderate |
| Internal Creation | Avoid if possible | High | Poor |
3. Factory/Provider Pattern
A factory or provider is injected that creates delegates on demand:
class ReportGenerator {
constructor(private formatterFactory: FormatterFactory) {}
generate(report: Report, formatType: string): string {
// Factory creates appropriate formatter based on context
const formatter = this.formatterFactory.create(formatType);
return formatter.format(report);
}
}
Advantages:
4. Internal Creation (Anti-Pattern)
The delegator creates its delegate internally:
class OrderProcessor {
private paymentGateway = new StripeGateway(); // AVOID!
}
Why to avoid:
Creating delegates internally using new ConcreteDelegate() defeats the purpose of delegation. You're tightly coupled to a specific implementation. Always prefer injection of interfaces over internal instantiation of concretes. If you must create internally, at least extract to a factory method that can be overridden for testing.
Once the delegator has a delegate, requests must be forwarded. The forwarding mechanism can take several forms:
Pattern 1: Direct Forwarding
The simplest form—forward the request directly to the delegate:
class Printer {
constructor(private formatter: DocumentFormatter) {}
format(document: Document): string {
return this.formatter.format(document); // Direct forward
}
}
The delegator's method has the same signature as the delegate's. It simply passes through.
Pattern 2: Adapted Forwarding
The delegator transforms the request before forwarding:
class NotificationService {
constructor(private emailService: EmailService) {}
notifyUser(user: User, event: Event): void {
// Transform: convert domain objects to email parameters
const subject = `Notification: ${event.title}`;
const body = this.formatEventForEmail(event);
// Forward with adapted parameters
this.emailService.sendEmail(user.email, subject, body);
}
}
Here, the delegator provides a higher-level interface (notifyUser) that internally uses the delegate's lower-level interface (sendEmail).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Pattern 1: Direct Forwarding// The delegator's interface mirrors the delegate'sclass StringList { private items: string[] = []; // Direct forwarding to Array methods push(item: string): number { return this.items.push(item); // Forward to array } pop(): string | undefined { return this.items.pop(); // Forward to array } length(): number { return this.items.length; // Forward property access }} // Pattern 2: Adapted Forwarding// The delegator provides a different interfaceclass UserRepository { constructor(private database: DatabaseConnection) {} // High-level domain method async findActiveUsers(): Promise<User[]> { // Adapt to low-level database query const rows = await this.database.query( 'SELECT * FROM users WHERE status = ?', ['active'] ); // Transform results return rows.map(row => this.mapToUser(row)); } private mapToUser(row: Record<string, any>): User { return new User(row.id, row.name, row.email); }} // Pattern 3: Aggregated Forwarding// Delegator coordinates multiple delegatesclass OrderFulfillment { constructor( private inventory: InventoryService, private shipping: ShippingService, private notifications: NotificationService ) {} async fulfillOrder(order: Order): Promise<FulfillmentResult> { // Forward to multiple delegates, coordinating their work await this.inventory.reserve(order.items); const shipment = await this.shipping.createShipment(order); await this.notifications.send( order.customer, `Order shipped! Tracking: ${shipment.trackingNumber}` ); return { shipment, status: 'fulfilled' }; }}Pattern 3: Aggregated/Coordinating Forwarding
The delegator forwards to multiple delegates, coordinating their results (seen in the code above). This is common in service facades and transaction coordinators.
Pattern 4: Conditional Forwarding
The delegator chooses which delegate to use based on runtime conditions:
class PaymentProcessor {
constructor(
private cardProcessor: CardProcessor,
private bankTransferProcessor: BankTransferProcessor
) {}
process(payment: Payment): Result {
if (payment.method === 'card') {
return this.cardProcessor.process(payment);
} else {
return this.bankTransferProcessor.process(payment);
}
}
}
Note: This can often be improved by having a map of strategies or using a factory, reducing the conditional logic.
When the delegator's interface matches the delegate's exactly (direct forwarding), the delegator is often implementing the same interface as the delegate—this is the Decorator pattern. When the interfaces differ (adapted forwarding), the delegator is providing a facade or adapter layer, translating between abstraction levels.
Effective delegation requires well-designed interfaces between delegators and delegates. The interface is the contract that enables polymorphic substitution—without it, you're just calling methods on a specific class.
Principle 1: Depend on Interfaces, Not Implementations
The delegator should hold a reference to an interface (or abstract class), not a concrete class:
// GOOD: depends on interface
class Logger {
constructor(private writer: LogWriter) {} // Interface
}
// BAD: depends on concrete class
class Logger {
constructor(private writer: FileLogWriter) {} // Concrete
}
Depending on an interface allows any implementation to be substituted without changing the delegator.
Principle 2: Interfaces Should Be Narrow
Follow the Interface Segregation Principle—interfaces should be small and focused:
// GOOD: narrow, focused interface
interface DocumentFormatter {
format(doc: Document): string;
}
// BAD: fat interface with unrelated methods
interface DocumentHandler {
format(doc: Document): string;
save(doc: Document): void;
print(doc: Document): void;
email(doc: Document, recipient: string): void;
}
Narrow interfaces make it easier to create mock implementations and avoid forcing classes to implement methods they don't need.
Principle 3: Design Interfaces Around Abstraction Levels
Interfaces should match the abstraction level of the delegator:
// High-abstraction delegator needs high-abstraction interface
interface OrderProcessor {
processOrder(order: Order): OrderResult;
}
// Low-abstraction delegator might use low-abstraction interface
interface DatabaseConnection {
query(sql: string, params: any[]): Row[];
}
Don't make a high-level business service depend on a low-level data-structure interface. If needed, create an adapter or facade that translates between levels.
Principle 4: Consider Callback/Event Interfaces
Sometimes the delegate needs to communicate back to the delegator. Design interfaces that support bidirectional communication when needed:
interface DownloadTask {
download(url: string, listener: DownloadListener): void;
}
interface DownloadListener {
onProgress(percent: number): void;
onComplete(file: File): void;
onError(error: Error): void;
}
When you need to add capabilities to an interface, consider creating a new, extended interface rather than modifying the original. This preserves backward compatibility. Implementations can then choose which interface(s) to implement based on their capabilities.
When forwarding requests, the delegator often needs to provide context to the delegate. How much context to pass, and in what form, is a design decision with tradeoffs.
Strategy 1: Minimal Parameters
Pass only what the delegate needs:
interface Formatter {
format(text: string): string;
}
class Document {
constructor(private formatter: Formatter) {}
render(): string {
return this.formatter.format(this.content); // Pass just the content
}
}
Advantage: Loose coupling—delegate knows nothing about the Document.
Strategy 2: Context Object
Pass a context object with relevant information:
interface RenderContext {
readonly content: string;
readonly metadata: Metadata;
readonly options: RenderOptions;
}
interface Renderer {
render(context: RenderContext): string;
}
class Document {
render(): string {
const context = {
content: this.content,
metadata: this.metadata,
options: this.options
};
return this.renderer.render(context);
}
}
Advantage: Clean grouping of related data; easy to extend without changing signatures.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Strategy 3: Pass the Delegator Itself// When the delegate needs to call back or access multiple aspects interface Validator { validate(target: Validatable): ValidationResult;} interface Validatable { getValue(): any; getConstraints(): Constraint[]; getName(): string;} class FormField implements Validatable { constructor( private name: string, private value: any, private constraints: Constraint[], private validator: Validator ) {} getValue(): any { return this.value; } getConstraints(): Constraint[] { return this.constraints; } getName(): string { return this.name; } validate(): ValidationResult { // Pass self to delegate return this.validator.validate(this); }} // Strategy 4: Request/Response Objects// For complex interactions interface Request { readonly type: string; readonly payload: any; readonly metadata: Map<string, any>;} interface Response { readonly success: boolean; readonly data?: any; readonly errors?: Error[];} interface Handler { handle(request: Request): Promise<Response>;} class Gateway { constructor(private handlers: Map<string, Handler>) {} async dispatch(request: Request): Promise<Response> { const handler = this.handlers.get(request.type); if (!handler) { return { success: false, errors: [new Error('No handler')] }; } return handler.handle(request); }}Strategy 3: Pass the Delegator Itself
When the delegate needs access to multiple aspects of the delegator, pass this (or an interface view of it). This is true delegation in the academic sense, where the delegate can reference the original receiver.
Strategy 4: Request/Response Objects
For complex interactions, use an encapsulated request and response pattern. This is particularly useful in command handlers, middleware chains, and plugin architectures.
Choosing the Right Strategy
| Strategy | When to Use | Coupling | Flexibility |
|---|---|---|---|
| Minimal Parameters | Simple, stable interactions | Very Low | Limited |
| Context Object | Multiple related parameters | Low | Moderate (can extend) |
| Pass Self/Interface | Delegate needs callback access | Moderate | High |
| Request/Response | Complex, extensible protocols | Low | Very High |
Start with minimal parameters. If you find yourself adding more and more parameters, consider a context object. If the delegate needs to interact heavily with the delegator, consider passing an interface-limited view of self. Evolve the design as complexity grows.
Delegates have lifecycles—they're created, used, and eventually disposed. How the delegator manages this lifecycle affects resource management and overall system health.
Lifecycle Pattern 1: Owned Delegates
The delegator owns the delegate's lifecycle—creating and potentially destroying it:
class ConnectionPool {
private connections: DatabaseConnection[] = [];
constructor(private connectionFactory: ConnectionFactory) {
// Create owned delegates
for (let i = 0; i < 10; i++) {
this.connections.push(connectionFactory.create());
}
}
close(): void {
// Responsible for cleanup
this.connections.forEach(conn => conn.close());
}
}
Lifecycle Pattern 2: Injected (Non-Owned) Delegates
The delegate is injected; the delegator doesn't manage its lifecycle:
class OrderService {
// Delegate is injected; lifecycle managed externally
constructor(private paymentGateway: PaymentGateway) {}
// No cleanup responsibility—PaymentGateway lifecycle is external
}
This is the common case with dependency injection. The IoC container or calling code manages the delegate's lifecycle.
Lifecycle Pattern 3: Scoped Delegates (Per-Request)
Delegates are created for specific contexts/requests and disposed afterward:
class RequestHandler {
constructor(private sessionFactory: SessionFactory) {}
async handle(request: Request): Promise<Response> {
// Create scoped delegate
const session = this.sessionFactory.createSession();
try {
// Use delegate
const result = await this.processWithSession(session, request);
await session.commit();
return result;
} catch (error) {
await session.rollback();
throw error;
} finally {
// Dispose scoped delegate
await session.close();
}
}
}
Lifecycle Pattern 4: Lazy Delegates
Delegate is created only when first needed:
class ExpensiveProcessor {
private _delegate?: ExpensiveResource;
private get delegate(): ExpensiveResource {
if (!this._delegate) {
this._delegate = this.createExpensiveResource();
}
return this._delegate;
}
process(data: Data): Result {
return this.delegate.process(data);
}
}
Useful when the delegate is expensive to create and may not always be needed.
When delegates hold resources (database connections, file handles, network sockets), always ensure proper cleanup. Use try-finally blocks, disposable/autocloseable patterns, or container-managed scopes. Resource leaks from poorly-managed delegates can bring production systems down.
When a delegate fails, the delegator must decide how to handle that failure. Error handling in delegation requires careful consideration of abstraction levels and caller expectations.
Strategy 1: Transparent Propagation
Let the delegate's exceptions bubble up unchanged:
class OrderService {
async placeOrder(order: Order): Promise<void> {
// If paymentGateway throws PaymentFailedError, it propagates
await this.paymentGateway.charge(order.total);
}
}
When appropriate: When the exception is meaningful at the caller's abstraction level.
Strategy 2: Exception Translation
Catch and re-throw as a different exception type:
class UserService {
async getUser(id: string): Promise<User> {
try {
return await this.database.query(...);
} catch (error) {
if (error instanceof DatabaseError) {
// Translate to domain exception
throw new UserNotFoundError(id);
}
throw error;
}
}
}
When appropriate: When the delegate's exception exposes implementation details or is at the wrong abstraction level.
| Strategy | Description | When to Use |
|---|---|---|
| Transparent Propagation | Let exceptions bubble up unchanged | Exception is meaningful to callers |
| Exception Translation | Catch and rethrow as different type | Hide implementation details; match abstraction |
| Fallback/Recovery | Catch and continue with alternative | Graceful degradation is acceptable |
| Retry | Catch and retry with delegate | Transient failures are likely |
| Circuit Breaker | Stop delegating after repeated failures | Protect from cascading failures |
| Default Value | Return sensible default on failure | Missing data is acceptable |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Strategy 3: Fallback / Recoveryclass PricingService { constructor( private primaryPricing: PricingEngine, private fallbackPricing: PricingEngine ) {} async getPrice(product: Product): Promise<Price> { try { return await this.primaryPricing.calculate(product); } catch (error) { console.warn('Primary pricing failed, using fallback', error); // Delegate to fallback instead return await this.fallbackPricing.calculate(product); } }} // Strategy 4: Retry with Backoffclass ResilientGateway { constructor(private gateway: PaymentGateway) {} async charge(amount: number): Promise<ChargeResult> { const maxRetries = 3; let lastError: Error | undefined; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await this.gateway.charge(amount); } catch (error) { lastError = error as Error; if (!this.isRetryable(error)) { throw error; } await this.wait(Math.pow(2, attempt) * 100); } } throw new Error(`Failed after ${maxRetries} attempts: ${lastError?.message}`); } private isRetryable(error: unknown): boolean { return error instanceof TransientError; } private wait(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }} // Strategy 5: Circuit Breakerclass CircuitBreakerGateway { private failures = 0; private circuitOpen = false; private lastFailure?: Date; constructor( private gateway: PaymentGateway, private failureThreshold = 5, private recoveryTime = 60000 ) {} async charge(amount: number): Promise<ChargeResult> { if (this.circuitOpen) { if (Date.now() - (this.lastFailure?.getTime() ?? 0) < this.recoveryTime) { throw new Error('Circuit breaker is open'); } this.circuitOpen = false; this.failures = 0; } try { const result = await this.gateway.charge(amount); this.failures = 0; return result; } catch (error) { this.failures++; this.lastFailure = new Date(); if (this.failures >= this.failureThreshold) { this.circuitOpen = true; } throw error; } }}The right error-handling strategy depends on your reliability requirements. For critical paths, implement retry and circuit breaker patterns. For non-critical features, fallbacks and default values provide graceful degradation. Always consider what happens to the user when delegation fails.
One of delegation's greatest benefits is testability. Because delegates are injected through interfaces, they can be easily replaced with test doubles. Let's examine how to leverage this.
Mock Delegates for Isolation
Replace real delegates with mocks to test the delegator in isolation:
describe('OrderService', () => {
it('should charge the correct amount', async () => {
// Create mock delegate
const mockPaymentGateway = {
charge: jest.fn().mockResolvedValue({ success: true })
};
// Inject mock
const service = new OrderService(mockPaymentGateway);
// Exercise
await service.placeOrder({ total: 100 });
// Verify delegation occurred correctly
expect(mockPaymentGateway.charge).toHaveBeenCalledWith(100);
});
});
Stub Delegates for Controlled Responses
Configure delegate behavior to test different scenarios:
it('should handle payment failure gracefully', async () => {
const mockGateway = {
charge: jest.fn().mockRejectedValue(new PaymentFailedError())
};
const service = new OrderService(mockGateway);
await expect(service.placeOrder({ total: 100 }))
.rejects.toThrow('Payment failed');
});
Testing Delegation Coordination
When delegators coordinate multiple delegates, verify the workflow:
it('should fulfill order in correct sequence', async () => {
const inventory = { reserve: jest.fn() };
const shipping = { createShipment: jest.fn().mockResolvedValue({ id: '1' }) };
const notifications = { send: jest.fn() };
const fulfillment = new OrderFulfillment(inventory, shipping, notifications);
await fulfillment.fulfillOrder(testOrder);
// Verify coordination
expect(inventory.reserve).toHaveBeenCalledBefore(shipping.createShipment);
expect(shipping.createShipment).toHaveBeenCalledBefore(notifications.send);
});
Integration Tests with Real Delegates
While unit tests use mocks, integration tests verify that real delegates work correctly together:
describe('OrderFulfillment Integration', () => {
it('should work with real services', async () => {
const fulfillment = new OrderFulfillment(
new RealInventoryService(testDb),
new RealShippingService(testConfig),
new RealNotificationService(testSmtp)
);
const result = await fulfillment.fulfillOrder(testOrder);
expect(result.status).toBe('fulfilled');
});
});
Delegation enables effective testing at all levels. Unit tests with mocks verify delegator logic in isolation. Integration tests verify delegates work together. E2E tests verify the complete system. Clear delegation boundaries make this pyramid achievable.
We've explored the practical mechanics of forwarding requests to contained objects. Let's consolidate the key insights:
What's Next:
With the mechanics of delegation well understood, the next page compares Delegation vs Inheritance directly. We'll examine how delegation achieves similar goals to inheritance but with greater flexibility, and clarify when each approach is appropriate.
You now understand the practical mechanics of forwarding requests to delegates—how to acquire delegates, forward requests, design interfaces, handle lifecycles and errors, and test effectively. These patterns form the implementation backbone of delegation-based designs. Next, we'll directly compare delegation with inheritance.