Loading content...
When faced with extending object behavior, developers have two primary mechanisms: inheritance (the "is-a" relationship) and composition (the "has-a" relationship, which Decorator leverages). These aren't equivalent alternatives—each has distinct characteristics that make it superior for specific situations.
This page provides a rigorous comparison to help you make informed decisions. We'll examine the theoretical foundations, practical implications, and the nuanced scenarios where each approach excels.
By the end of this page, you will understand the fundamental differences between decoration and inheritance, know the criteria for choosing between them, and recognize common scenarios where each is the clear winner. You'll develop judgment for making this architectural decision confidently.
Let's begin with a side-by-side structural comparison of how the same capability—adding logging to a repository—would be implemented with each approach:
12345678910111213141516171819202122232425262728293031323334353637
// Base classclass UserRepository { async findById(id: string): Promise<User | null> { // Database query logic return await this.db.users.findOne({ id }); } async save(user: User): Promise<void> { await this.db.users.upsert(user); }} // Extended class with logging (inheritance)class LoggedUserRepository extends UserRepository { private logger: Logger; constructor(db: Database, logger: Logger) { super(db); this.logger = logger; } async findById(id: string): Promise<User | null> { this.logger.info(`Finding user: ${id}`); const result = await super.findById(id); this.logger.info(`Found user: ${result ? 'yes' : 'no'}`); return result; } async save(user: User): Promise<void> { this.logger.info(`Saving user: ${user.id}`); await super.save(user); this.logger.info(`Saved user: ${user.id}`); }} // Usageconst repo = new LoggedUserRepository(db, logger);1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Interface defining the contractinterface Repository<T> { findById(id: string): Promise<T | null>; save(entity: T): Promise<void>;} // Core implementationclass UserRepository implements Repository<User> { async findById(id: string): Promise<User | null> { return await this.db.users.findOne({ id }); } async save(user: User): Promise<void> { await this.db.users.upsert(user); }} // Decorator with logging (composition)class LoggingRepositoryDecorator<T> implements Repository<T> { private wrapped: Repository<T>; private logger: Logger; constructor(repository: Repository<T>, logger: Logger) { this.wrapped = repository; this.logger = logger; } async findById(id: string): Promise<T | null> { this.logger.info(`Finding entity: ${id}`); const result = await this.wrapped.findById(id); this.logger.info(`Found entity: ${result ? 'yes' : 'no'}`); return result; } async save(entity: T): Promise<void> { this.logger.info(`Saving entity`); await this.wrapped.save(entity); this.logger.info(`Saved entity`); }} // Usageconst userRepo = new UserRepository(db);const loggedRepo = new LoggingRepositoryDecorator(userRepo, logger);At first glance, these look similar—both add logging around method calls. The differences become apparent when we consider reusability and composition.
| Aspect | Inheritance | Decorator |
|---|---|---|
| Relationship | LoggedUserRepository IS-A UserRepository | LoggingDecorator HAS-A Repository |
| Binding Time | Compile-time (fixed at class definition) | Runtime (configurable at object creation) |
| Reusability | Specific to UserRepository | Works with ProductRepository, OrderRepository, any Repository<T> |
| Adding Caching | Need: LoggedCachedUserRepository (new class) | Wrap: new CachingDecorator(loggedRepo) |
| Multiple Enhancements | One class per combination needed | Stack decorators in any order |
The Decorator pattern embodies the "Favor composition over inheritance" principle from the Gang of Four. This isn't a dogmatic rejection of inheritance—it's a recognition that composition provides greater flexibility for behavioral extension.
Let's examine why composition (via decoration) is often preferable:
12345678910111213141516171819202122232425262728293031323334353637383940414243
// With Decorator: ONE logging class works for ALL repository types class LoggingDecorator<T> implements Repository<T> { constructor(private wrapped: Repository<T>, private logger: Logger) {} async findById(id: string): Promise<T | null> { this.logger.info(`Finding: ${id}`); return this.wrapped.findById(id); } async save(entity: T): Promise<void> { this.logger.info('Saving entity'); await this.wrapped.save(entity); }} // Reuse for ANY repository typeconst loggedUsers = new LoggingDecorator(userRepo, logger);const loggedProducts = new LoggingDecorator(productRepo, logger);const loggedOrders = new LoggingDecorator(orderRepo, logger);const loggedPayments = new LoggingDecorator(paymentRepo, logger); // ----------------------------------------------------------------// With Inheritance: SEPARATE class for EACH entity type class LoggedUserRepository extends UserRepository { // Logging implementation for User} class LoggedProductRepository extends ProductRepository { // Duplicate logging implementation for Product} class LoggedOrderRepository extends OrderRepository { // Duplicate logging implementation for Order} class LoggedPaymentRepository extends PaymentRepository { // Duplicate logging implementation for Payment} // 4 entity types × 4 behaviors = 16 classes with inheritance// 4 entity classes + 4 decorator classes = 8 classes with decorationInheritance-based extension often violates DRY (Don't Repeat Yourself) when the same behavior must be added to multiple class hierarchies. The decorator's generic wrapper eliminates this duplication by extracting the cross-cutting concern into a single, reusable class.
Despite the Decorator's advantages, inheritance remains the right choice in specific scenarios. Understanding these cases prevents over-application of the Decorator pattern.
SavingsAccount truly IS an Account. A Car truly IS a Vehicle. This isn't about adding behavior—it's about specialization.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// Example 1: True IS-A relationship with specialization// This is appropriate inheritance—not behavioral extension abstract class Shape { abstract area(): number; abstract perimeter(): number;} class Rectangle extends Shape { constructor(private width: number, private height: number) { super(); } area(): number { return this.width * this.height; } perimeter(): number { return 2 * (this.width + this.height); }} class Square extends Rectangle { // Square IS-A Rectangle with equal sides // This is proper use of inheritance constructor(side: number) { super(side, side); }} // ----------------------------------------------------------------// Example 2: Template method with protected hooks// Subclass provides specific behavior for abstract steps abstract class DocumentProcessor { // Template method defines the algorithm process(doc: Document): ProcessedDocument { const validated = this.validate(doc); const transformed = this.transform(validated); const formatted = this.format(transformed); return formatted; } // Protected hooks for subclass customization protected abstract validate(doc: Document): Document; protected abstract transform(doc: Document): Document; protected abstract format(doc: Document): ProcessedDocument;} class MarkdownProcessor extends DocumentProcessor { protected validate(doc: Document): Document { // Markdown-specific validation return doc; } protected transform(doc: Document): Document { // Markdown-specific transformation return doc; } protected format(doc: Document): ProcessedDocument { // Markdown-specific formatting return new MarkdownDocument(doc); }} // This is inheritance for SPECIALIZATION, not behavioral addition// A decorator wouldn't have access to the protected hooksAsk yourself: "Am I creating a specialized TYPE or adding a cross-cutting BEHAVIOR?" Specialized types (SavingsAccount is a type of Account) fit inheritance. Cross-cutting behaviors (any account can have logging) fit decoration.
The Decorator pattern excels in scenarios involving cross-cutting concerns, optional features, and runtime flexibility:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Scenario 1: Cross-cutting concerns for multiple types// One decorator works for all implementations interface HttpClient { request(url: string, options: RequestOptions): Promise<Response>;} class LoggingHttpClient implements HttpClient { constructor(private client: HttpClient, private logger: Logger) {} async request(url: string, options: RequestOptions): Promise<Response> { this.logger.info(`HTTP ${options.method} ${url}`); const start = Date.now(); const response = await this.client.request(url, options); this.logger.info(`HTTP ${response.status} in ${Date.now() - start}ms`); return response; }} // Works with ANY HttpClient implementationconst fetchClient = new LoggingHttpClient(new FetchHttpClient(), logger);const axiosClient = new LoggingHttpClient(new AxiosHttpClient(), logger);const mockClient = new LoggingHttpClient(new MockHttpClient(), logger); // ----------------------------------------------------------------// Scenario 2: Runtime configuration with feature flags function createApiClient(config: AppConfig): HttpClient { let client: HttpClient = new FetchHttpClient(); if (config.featureFlags.enableRequestLogging) { client = new LoggingHttpClient(client, logger); } if (config.featureFlags.enableCircuitBreaker) { client = new CircuitBreakerHttpClient(client, circuitConfig); } if (config.featureFlags.enableRetries) { client = new RetryingHttpClient(client, { maxRetries: 3 }); } if (config.featureFlags.enableCaching) { client = new CachingHttpClient(client, cache); } return client;} // ----------------------------------------------------------------// Scenario 3: Wrapping third-party libraries you don't control // Stripe SDK is a final/external classconst stripeClient = new Stripe(apiKey); // Add logging, metrics, and retry behavior without modifying Stripeconst enhancedStripe = new RetryDecorator( new MetricsDecorator( new LoggingDecorator(stripeClient, logger), metrics ), { maxRetries: 3 });One significant difference between inheritance and decoration is object identity. With inheritance, the extended object IS the same object. With decoration, it's a different object wrapping the original.
1234567891011121314151617181920212223242526272829303132333435363738394041
// INHERITANCE: Identity preservedclass UserRepository {}class LoggedUserRepository extends UserRepository {} const repo = new LoggedUserRepository();console.log(repo instanceof UserRepository); // trueconsole.log(repo instanceof LoggedUserRepository); // true // Identity-based operations work as expectedconst repoSet = new Set<UserRepository>();repoSet.add(repo);repoSet.has(repo); // true // ----------------------------------------------------------------// DECORATOR: Identity NOT preservedinterface Repository<T> { findById(id: string): Promise<T | null>;} class UserRepositoryImpl implements Repository<User> { findById(id: string): Promise<User | null> { /* ... */ }} class LoggingDecorator<T> implements Repository<T> { constructor(private wrapped: Repository<T>) {} findById(id: string): Promise<T | null> { /* ... */ }} const baseRepo = new UserRepositoryImpl();const decoratedRepo = new LoggingDecorator(baseRepo); console.log(decoratedRepo instanceof UserRepositoryImpl); // FALSE!console.log(decoratedRepo === baseRepo); // FALSE! // This can break expectations:const repoMap = new Map<Repository<User>, string>();repoMap.set(baseRepo, "original");repoMap.get(decoratedRepo); // undefined! Different object // The decorated repo IS-A Repository<User> by interface// But NOT instanceof the concrete classIf your codebase relies heavily on instanceof checks or object identity comparisons, decorators can introduce subtle bugs. This is one of the tradeoffs—greater flexibility comes with identity complexity. Well-designed systems program to interfaces, minimizing this issue.
Inheritance and decoration have different performance characteristics. While the differences are rarely significant, understanding them helps make informed decisions for performance-critical code.
| Aspect | Inheritance | Decorator |
|---|---|---|
| Method Call Overhead | Single virtual dispatch (vtable lookup) | Multiple dispatches (one per decorator layer) |
| Memory Overhead | Single object allocation | One object allocation per decorator layer |
| JIT Optimization | Easier to inline (single type) | Harder to inline (multiple types in chain) |
| Cache Locality | Better (all data in one object) | Worse (data spread across objects) |
| Construction Cost | Single constructor call | Multiple constructor calls |
12345678910111213141516171819202122232425262728293031323334
// INHERITANCE: One hop// Method call: caller → [vtable lookup] → LoggedUserRepository.findById()// ↓// Goes to parent via super.findById()// Total: 2 method calls maximum class LoggedUserRepository extends UserRepository { async findById(id: string): Promise<User | null> { this.logger.info(`Finding user: ${id}`); return super.findById(id); // Direct call to parent }} // ----------------------------------------------------------------// DECORATOR: Multiple hops// Method call chain with 4 decorators:// caller → Decorator4.method() → Decorator3.method() → // Decorator2.method() → Decorator1.method() → ConcreteComponent.method()// Total: 5 method calls const fullyDecorated = new LoggingDecorator( // Call 1 new CachingDecorator( // Call 2 new MetricsDecorator( // Call 3 new RetryDecorator( // Call 4 new UserRepository() // Call 5 ) ) )); // Each layer adds:// - One method call// - One object dereference (wrapped.method())// - Potential cache misses from different object locationsPractical perspective:
For most applications, decorator overhead is negligible. The extra microseconds from method indirection are invisible compared to I/O operations, database queries, or network calls. You should only optimize for decorator overhead if:
In 99% of real-world code, the flexibility benefits far outweigh the minor performance cost.
Don't avoid decorators for performance reasons without evidence. Profile first, then optimize. If decorators ARE a bottleneck, consider consolidating multiple decorators into one composite decorator that performs all operations in a single pass.
Here's a practical decision framework to guide your choice between inheritance and decoration:
1234567891011121314151617181920212223242526272829303132333435363738
┌─────────────────────────────────────────────────────────────────┐│ DECISION: Should I use Inheritance or Decorator? │└─────────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────┐│ Q1: Is this a TRUE "is-a" relationship (specialization)? ││ Example: SavingsAccount IS-A Account │└─────────────────────────────────────────────────────────────────┘ │ YES │ NO ▼ ▼ ┌──────────────┐ ┌─────────────────────────────────┐ │ INHERITANCE │ │ Q2: Is this a cross-cutting │ │ (likely) │ │ concern (logging, caching, │ └──────────────┘ │ metrics, security)? │ └─────────────────────────────────┘ │ YES │ NO ▼ ▼ ┌──────────────┐ ┌──────────────────────┐ │ DECORATOR │ │ Q3: Do you need │ │ (likely) │ │ runtime │ └──────────────┘ │ flexibility? │ └──────────────────────┘ │ YES │ NO ▼ ▼ ┌──────────┐ ┌────────────────┐ │DECORATOR │ │ Q4: Need access│ └──────────┘ │ to protected │ │ members? │ └────────────────┘ │ YES │ NO ▼ ▼ ┌───────────┐ ┌──────────┐ │INHERITANCE│ │ Either │ └───────────┘ │ works— │ │ prefer │ │ simpler │ └──────────┘Inheritance and decoration aren't mutually exclusive. Sophisticated designs often use both—inheritance for type specialization and decoration for cross-cutting enhancement:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Type hierarchy using INHERITANCE (specialization)abstract class Account { abstract getType(): string; abstract calculateInterest(): number; // Common account behavior deposit(amount: number): void { /* ... */ } withdraw(amount: number): void { /* ... */ }} class SavingsAccount extends Account { getType(): string { return 'SAVINGS'; } calculateInterest(): number { return this.balance * 0.02; }} class CheckingAccount extends Account { getType(): string { return 'CHECKING'; } calculateInterest(): number { return 0; }} // Cross-cutting concerns using DECORATIONinterface AccountOperations { deposit(amount: number): void; withdraw(amount: number): void; getBalance(): number;} class AuditedAccount implements AccountOperations { constructor( private wrapped: AccountOperations, private auditLog: AuditLog ) {} deposit(amount: number): void { this.auditLog.record('DEPOSIT', amount); this.wrapped.deposit(amount); } withdraw(amount: number): void { this.auditLog.record('WITHDRAW', amount); this.wrapped.withdraw(amount); } getBalance(): number { return this.wrapped.getBalance(); }} class NotifyingAccount implements AccountOperations { constructor( private wrapped: AccountOperations, private notifier: NotificationService ) {} withdraw(amount: number): void { this.wrapped.withdraw(amount); if (amount > 10000) { this.notifier.sendAlert('Large withdrawal'); } } // ... other methods delegate} // USAGE: Combine both patterns// 1. Create specialized type via inheritanceconst savings = new SavingsAccount(); // 2. Add cross-cutting behaviors via decorationconst enhancedSavings = new NotifyingAccount( new AuditedAccount(savings, auditLog), notifier); // ANY account type can be decorated with auditing and notificationsconst checking = new CheckingAccount();const enhancedChecking = new NotifyingAccount( new AuditedAccount(checking, auditLog), notifier);Use inheritance to establish the type structure (what KINDS of accounts exist) and decoration to add orthogonal features (how ANY account can be enhanced). This separates the domain model from operational concerns elegantly.
Summary: Decorator vs Inheritance
Neither is universally better—each has its domain. Inheritance excels at creating type hierarchies with specialized behavior. Decoration excels at adding optional, combinable, cross-cutting features. The master architect knows when each is appropriate and often combines them for maximally flexible, maintainable designs.
What's next:
The final page explores practical use cases and examples of the Decorator pattern in real-world systems—from Java I/O streams to middleware stacks to UI components.
You now understand the tradeoffs between decoration and inheritance, can identify scenarios suited to each, and know how to combine them effectively. The next page provides extensive real-world examples demonstrating the Decorator pattern in practice.