Loading learning content...
Having explored when inheritance is appropriate, we now turn to the scenarios where composition is clearly the superior choice. These aren't just cases where composition "could work"—they're contexts where using inheritance would create design problems that become increasingly costly over time.\n\nUnderstanding these scenarios deeply transforms "favor composition" from an abstract guideline into a concrete design instinct. You'll learn to recognize the warning signs that signal composition is needed, and understand why inheritance fails in these contexts.
By the end of this page, you will recognize seven distinct scenarios where composition is clearly the right choice. You'll understand the specific problems that inheritance would cause in each context and see concrete examples that demonstrate composition's advantages.
When behavior needs to be configured, changed, or swapped at runtime, composition is the only viable option. Inheritance binds behavior at compile time; an object's class—and therefore its inherited behavior—cannot change after instantiation.\n\nThe Core Issue:\n\nWith inheritance, the question "what type is this object?" has a permanent answer. A Dog is always a Dog. But what if an employee gets promoted? What if a user's subscription tier changes? What if a document's encryption needs to switch algorithms?\n\nThese scenarios require behavior that changes during an object's lifetime—something inheritance fundamentally cannot provide.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// Runtime Behavior Configuration: Pricing Strategy // BAD: Inheritance locks pricing at instantiationclass Product { abstract calculatePrice(basePrice: number): number;} class RegularProduct extends Product { calculatePrice(basePrice: number) { return basePrice; }} class DiscountedProduct extends Product { calculatePrice(basePrice: number) { return basePrice * 0.8; }} class PremiumProduct extends Product { calculatePrice(basePrice: number) { return basePrice * 1.2; }} // Problem: What happens during a flash sale?// The product IS a RegularProduct - we can't change that!// We'd need to create a NEW object with all its state copied. // GOOD: Composition allows runtime pricing changesinterface PricingStrategy { calculate(basePrice: number): number;} class RegularPricing implements PricingStrategy { calculate(basePrice: number) { return basePrice; }} class DiscountPricing implements PricingStrategy { constructor(private discountPercent: number) {} calculate(basePrice: number) { return basePrice * (1 - this.discountPercent / 100); }} class FlashSalePricing implements PricingStrategy { constructor( private regularPricing: PricingStrategy, private saleMultiplier: number, private startTime: Date, private endTime: Date ) {} calculate(basePrice: number) { const now = new Date(); if (now >= this.startTime && now <= this.endTime) { return basePrice * this.saleMultiplier; } return this.regularPricing.calculate(basePrice); }} class Product { private pricingStrategy: PricingStrategy; constructor( public readonly name: string, public readonly basePrice: number, pricingStrategy: PricingStrategy = new RegularPricing() ) { this.pricingStrategy = pricingStrategy; } // Runtime strategy swapping! setPricingStrategy(strategy: PricingStrategy): void { this.pricingStrategy = strategy; } getPrice(): number { return this.pricingStrategy.calculate(this.basePrice); }} // Usage: Same product, different pricing over timeconst laptop = new Product("MacBook", 1999);console.log(laptop.getPrice()); // 1999 // Flash sale starts!laptop.setPricingStrategy(new FlashSalePricing( new RegularPricing(), 0.7, // 30% off new Date('2024-11-29'), new Date('2024-11-30')));console.log(laptop.getPrice()); // 1399.30 during sale // Sale ends, back to regular pricinglaptop.setPricingStrategy(new RegularPricing());Why Composition Wins:\n\n1. Object identity preserved: The same Product object persists. Its identity, state, and references remain unchanged—only its pricing behavior adapts.\n\n2. Strategy pattern emergence: This naturally leads to the Strategy design pattern, encapsulating algorithms in interchangeable objects.\n\n3. Configuration from external sources: Pricing rules can be loaded from a database, config file, or remote service without changing object creation.\n\n4. Testing advantage: Each pricing strategy can be tested independently. Mocking pricing for product tests is trivial.\n\n5. A/B testing capability: Different users can experience different pricing strategies without different product classes.
Whenever you ask 'what if this behavior needs to change while the program is running?'—that's a signal for composition. If the answer is 'recreate the object,' consider whether composition would preserve identity while allowing behavioral evolution.
When objects vary along multiple independent dimensions, inheritance creates a combinatorial explosion of classes. Composition handles each dimension independently, keeping the class count linear.\n\nThe Combinatorial Explosion Problem:\n\nSuppose you have an object that varies along three dimensions, each with three options:\n- Dimension A: 3 options\n- Dimension B: 3 options\n- Dimension C: 3 options\n\nWith inheritance: 3 × 3 × 3 = 27 classes\nWith composition: 3 + 3 + 3 = 9 component classes\n\nThe gap widens exponentially as dimensions or options increase.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// Multiple Dimensions of Variation: Document Processing // DIMENSIONS:// 1. Format: PDF, DOCX, HTML// 2. Encryption: None, AES, RSA// 3. Compression: None, ZIP, GZIP// 4. Watermark: None, Draft, Confidential // BAD: Inheritance would require 3 × 3 × 3 × 3 = 81 classes!// PdfNoEncryptionNoCompressionNoWatermark// PdfNoEncryptionNoCompressionDraftWatermark// PdfNoEncryptionNoCompressionConfidentialWatermark// PdfNoEncryptionZipNoWatermark// ... 77 more classes! // GOOD: Composition requires only 3 + 3 + 3 + 3 = 12 components interface DocumentFormat { byte[] render(Document doc); String getExtension();} interface Encryptor { byte[] encrypt(byte[] data); byte[] decrypt(byte[] data);} interface Compressor { byte[] compress(byte[] data); byte[] decompress(byte[] data);} interface Watermarker { byte[] applyWatermark(byte[] data, String watermarkText);} // Format implementations (3)class PdfFormat implements DocumentFormat { /* ... */ }class DocxFormat implements DocumentFormat { /* ... */ }class HtmlFormat implements DocumentFormat { /* ... */ } // Encryption implementations (3)class NoEncryption implements Encryptor { /* pass-through */ }class AesEncryptor implements Encryptor { /* AES encryption */ }class RsaEncryptor implements Encryptor { /* RSA encryption */ } // Compression implementations (3)class NoCompression implements Compressor { /* pass-through */ }class ZipCompressor implements Compressor { /* ZIP compression */ }class GzipCompressor implements Compressor { /* GZIP compression */ } // Watermark implementations (3)class NoWatermark implements Watermarker { /* pass-through */ }class DraftWatermark implements Watermarker { /* "DRAFT" overlay */ }class ConfidentialWatermark implements Watermarker { /* "CONFIDENTIAL" */ } // The document processor composes these componentspublic class DocumentProcessor { private final DocumentFormat format; private final Encryptor encryptor; private final Compressor compressor; private final Watermarker watermarker; public DocumentProcessor( DocumentFormat format, Encryptor encryptor, Compressor compressor, Watermarker watermarker) { this.format = format; this.encryptor = encryptor; this.compressor = compressor; this.watermarker = watermarker; } public byte[] process(Document doc) { byte[] data = format.render(doc); data = watermarker.applyWatermark(data, ""); data = compressor.compress(data); data = encryptor.encrypt(data); return data; } public String getFilename(String baseName) { return baseName + format.getExtension(); }} // Usage: Any combination with 12 component classesDocumentProcessor secureProcessor = new DocumentProcessor( new PdfFormat(), new AesEncryptor(), new GzipCompressor(), new ConfidentialWatermark()); DocumentProcessor simpleProcessor = new DocumentProcessor( new HtmlFormat(), new NoEncryption(), new NoCompression(), new NoWatermark()); // Adding a new dimension (e.g., logging) requires only 1 new interface// and N implementations - not multiplying the entire matrix!| Dimensions | Options per Dimension | Inheritance Classes | Composition Components |
|---|---|---|---|
| 2 | 3 | 9 | 6 |
| 3 | 3 | 27 | 9 |
| 4 | 3 | 81 | 12 |
| 5 | 3 | 243 | 15 |
| 3 | 5 | 125 | 15 |
| 4 | 4 | 256 | 16 |
This scenario often leads to the Decorator pattern, where behaviors are layered through composition. Each decorator wraps an object and adds one dimension of behavior. The java.io streams (BufferedInputStream wrapping FileInputStream wrapping...) exemplify this pattern.
Cross-cutting concerns—functionality that applies across many unrelated classes—are a classic composition use case. Logging, caching, authentication, validation, metrics, and tracing all cut across the primary type hierarchy.\n\nWhy Inheritance Fails:\n\n- Cross-cutting concerns don't fit neatly into a type hierarchy\n- A class can't inherit from both a domain parent AND a logging parent (single inheritance languages)\n- Even with multiple inheritance, adding logging to 50 classes means modifying 50 class declarations\n- Concerns should be composable: logging + caching + metrics on the same object
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
// Cross-Cutting Concerns: Layered Behavior // The core service interfaceinterface UserService { findById(id: string): Promise<User | null>; save(user: User): Promise<void>; delete(id: string): Promise<void>;} // Core implementation - just business logicclass UserServiceImpl implements UserService { constructor(private readonly repository: UserRepository) {} async findById(id: string): Promise<User | null> { return this.repository.findById(id); } async save(user: User): Promise<void> { await this.repository.save(user); } async delete(id: string): Promise<void> { await this.repository.delete(id); }} // Cross-cutting: Logging decoratorclass LoggingUserService implements UserService { constructor( private readonly delegate: UserService, private readonly logger: Logger ) {} async findById(id: string): Promise<User | null> { this.logger.debug(`Finding user by id: ${id}`); const result = await this.delegate.findById(id); this.logger.debug(`Found user: ${result?.name ?? 'null'}`); return result; } async save(user: User): Promise<void> { this.logger.info(`Saving user: ${user.id}`); await this.delegate.save(user); this.logger.info(`User saved: ${user.id}`); } async delete(id: string): Promise<void> { this.logger.warn(`Deleting user: ${id}`); await this.delegate.delete(id); this.logger.warn(`User deleted: ${id}`); }} // Cross-cutting: Caching decoratorclass CachingUserService implements UserService { private cache: Map<string, User> = new Map(); constructor( private readonly delegate: UserService, private readonly cacheTtlMs: number ) {} async findById(id: string): Promise<User | null> { const cached = this.cache.get(id); if (cached) return cached; const user = await this.delegate.findById(id); if (user) { this.cache.set(id, user); setTimeout(() => this.cache.delete(id), this.cacheTtlMs); } return user; } async save(user: User): Promise<void> { await this.delegate.save(user); this.cache.set(user.id, user); // Update cache } async delete(id: string): Promise<void> { await this.delegate.delete(id); this.cache.delete(id); // Invalidate cache }} // Cross-cutting: Metrics decoratorclass MetricsUserService implements UserService { constructor( private readonly delegate: UserService, private readonly metrics: MetricsClient ) {} async findById(id: string): Promise<User | null> { const startTime = Date.now(); try { const result = await this.delegate.findById(id); this.metrics.timing('user_service.find_by_id', Date.now() - startTime); this.metrics.increment('user_service.find_by_id.success'); return result; } catch (error) { this.metrics.increment('user_service.find_by_id.error'); throw error; } } // ... similar for other methods} // Cross-cutting: Authorization decoratorclass AuthorizedUserService implements UserService { constructor( private readonly delegate: UserService, private readonly authContext: AuthContext ) {} async delete(id: string): Promise<void> { if (!this.authContext.hasPermission('user:delete')) { throw new UnauthorizedError('Cannot delete users'); } await this.delegate.delete(id); } // ... other methods with appropriate checks} // Compose all concerns together!const repository = new PostgresUserRepository(connectionPool);const baseService = new UserServiceImpl(repository); const userService: UserService = new LoggingUserService( new MetricsUserService( new CachingUserService( new AuthorizedUserService( baseService, authContext ), 60000 // 1 minute cache ), metricsClient ), logger ); // Clean composition: each concern is independent and reusable// OrderService could use the same LoggingDecorator patternKey Benefits of Compositional Cross-Cutting:\n\n1. Reusability: The logging decorator works for ANY service implementing the interface, not just UserService.\n\n2. Configurable stacking: Choose which concerns apply and in what order. Production might have logging + metrics + caching; development might only have logging.\n\n3. Separation of concerns: Each decorator handles ONE thing. Business logic is pure; decorators add orthogonal behavior.\n\n4. Testing: Test the core service without decorators. Test each decorator with a mock delegate.\n\n5. Open/Closed: Add new cross-cutting concerns without modifying existing code—just wrap with a new decorator.
This pattern is what Aspect-Oriented Programming (AOP) frameworks automate. Spring AOP, AspectJ, and similar tools generate these decorating wrappers automatically. Understanding the underlying composition pattern helps you use AOP effectively.
When working with external or third-party code you don't control, composition is strongly preferred over inheritance. Inheriting from classes outside your codebase exposes you to the fragile base class problem with no ability to fix it.\n\nThe Risks of Inheriting External Code:\n\n- External library updates may break your subclass without warning\n- You depend on undocumented implementation details\n- You can't influence the base class evolution\n- Bug fixes in your subclass may be invalidated by library changes\n- You're locked to a specific library version or face migration work
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// External Dependencies: The Famous Broken HashSet Example // Joshua Bloch's Effective Java Example - DO NOT DO THIS // BAD: Inheriting from a JDK collectionpublic class InstrumentedHashSet<E> extends HashSet<E> { private int addCount = 0; @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); } public int getAddCount() { return addCount; }} // The problem:InstrumentedHashSet<String> s = new InstrumentedHashSet<>();s.addAll(Arrays.asList("A", "B", "C"));System.out.println(s.getAddCount()); // Expected: 3, Actual: 6! // Why? HashSet.addAll() internally calls add() for each element!// Our addAll incremented by 3, then each add() incremented again.// This is an IMPLEMENTATION DETAIL we couldn't have known without// reading HashSet source code - and it could change in future JDKs! // GOOD: Composition with delegation - no inheritance from HashSetpublic class InstrumentedSet<E> implements Set<E> { private final Set<E> delegate; private int addCount = 0; public InstrumentedSet(Set<E> delegate) { this.delegate = delegate; } @Override public boolean add(E e) { addCount++; return delegate.add(e); } @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return delegate.addAll(c); // We call delegate.addAll(), which calls delegate.add() // NOT our add() - so no double counting! } public int getAddCount() { return addCount; } // Delegate all other Set methods @Override public int size() { return delegate.size(); } @Override public boolean isEmpty() { return delegate.isEmpty(); } @Override public boolean contains(Object o) { return delegate.contains(o); } @Override public Iterator<E> iterator() { return delegate.iterator(); } // ... remaining Set methods delegated} // Usage - works correctlyInstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());s.addAll(Arrays.asList("A", "B", "C"));System.out.println(s.getAddCount()); // Correctly prints: 3 // Bonus: Works with ANY Set implementationInstrumentedSet<String> treeSet = new InstrumentedSet<>(new TreeSet<>());InstrumentedSet<String> linkedSet = new InstrumentedSet<>(new LinkedHashSet<>());The Wrapper/Adapter Pattern:\n\nComposition naturally leads to wrapper patterns when integrating external code:\n\n- Wrapper: Adds behavior around external code (like InstrumentedSet)\n- Adapter: Transforms external interfaces to match your interfaces\n- Facade: Simplifies complex external APIs into cleaner internal interfaces\n\nAll these patterns use composition to create an insulating layer between your code and external dependencies.
The major exception is framework-designed extension points (covered in the inheritance scenarios). When a framework explicitly documents 'extend this class,' that's intended inheritance. But even then, extend with caution and expect evolution between framework versions.
When testability is a priority—and in professional software development, it should always be—composition provides dramatic advantages over inheritance. The ability to mock, stub, and isolate components makes the difference between comprehensive testing and testing nightmares.\n\nInheritance Testing Challenges:\n\n- Testing a subclass requires instantiating the entire inheritance chain\n- Parent class dependencies (database, network, etc.) infect subclass tests\n- Overriding behavior for testing is awkward and may not fully isolate\n- Integration with parent is implicitly tested even in "unit" tests
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
// Testability: Composition Enables Clean Testing // --- Inheritance Approach: Difficult to Test --- abstract class BaseOrderProcessor { protected database: Database; // Concrete dependency! protected emailService: EmailService; // Another dependency! constructor() { this.database = new PostgresDatabase(); // Hard-coded! this.emailService = new SmtpEmailService(); // Hard-coded! } process(order: Order): void { this.validate(order); this.database.save(order); // Hits real database this.emailService.send(order); // Sends real email this.onProcessed(order); } protected abstract validate(order: Order): void; protected abstract onProcessed(order: Order): void;} class PremiumOrderProcessor extends BaseOrderProcessor { protected validate(order: Order): void { if (order.total < 1000) throw new Error("Premium minimum $1000"); } protected onProcessed(order: Order): void { // Premium-specific logic }} // Testing is PAINFUL:describe('PremiumOrderProcessor', () => { it('validates minimum order amount', () => { // Problem 1: Instantiation creates real database connection // Problem 2: Test might send real emails // Problem 3: Database state affects test outcomes // We'd need to: // - Mock global singletons // - Use special test database // - Intercept email at SMTP level // This is integration testing, not unit testing! });}); // --- Composition Approach: Easy to Test --- interface OrderRepository { save(order: Order): Promise<void>;} interface NotificationService { sendOrderConfirmation(order: Order): Promise<void>;} interface OrderValidator { validate(order: Order): ValidationResult;} class OrderProcessor { constructor( private readonly repository: OrderRepository, private readonly notificationService: NotificationService, private readonly validator: OrderValidator ) {} async process(order: Order): Promise<void> { const validation = this.validator.validate(order); if (!validation.isValid) { throw new ValidationError(validation.errors); } await this.repository.save(order); await this.notificationService.sendOrderConfirmation(order); }} class PremiumOrderValidator implements OrderValidator { validate(order: Order): ValidationResult { if (order.total < 1000) { return ValidationResult.invalid(["Premium minimum $1000"]); } return ValidationResult.valid(); }} // Testing is CLEAN:describe('OrderProcessor', () => { it('validates orders before processing', async () => { // Arrange: Create mocks const mockRepository: OrderRepository = { save: jest.fn().mockResolvedValue(undefined) }; const mockNotification: NotificationService = { sendOrderConfirmation: jest.fn().mockResolvedValue(undefined) }; const mockValidator: OrderValidator = { validate: jest.fn().mockReturnValue(ValidationResult.valid()) }; const processor = new OrderProcessor( mockRepository, mockNotification, mockValidator ); const order = createTestOrder({ total: 500 }); // Act await processor.process(order); // Assert expect(mockValidator.validate).toHaveBeenCalledWith(order); expect(mockRepository.save).toHaveBeenCalledWith(order); expect(mockNotification.sendOrderConfirmation).toHaveBeenCalledWith(order); }); it('rejects invalid orders without saving', async () => { const mockRepository: OrderRepository = { save: jest.fn() }; const mockValidator: OrderValidator = { validate: () => ValidationResult.invalid(["Too small"]) }; const processor = new OrderProcessor( mockRepository, /* notification */ { sendOrderConfirmation: jest.fn() }, mockValidator ); await expect(processor.process(createTestOrder())) .rejects.toThrow(ValidationError); // Repository was never called expect(mockRepository.save).not.toHaveBeenCalled(); });});Composition naturally leads to dependency injection: dependencies are passed in (injected) rather than created internally. This is fundamental to testable code. If you find yourself unable to mock a dependency, that's often a sign that composition would improve the design.
When variations are based on roles, capabilities, or permissions rather than fundamental types, composition is the clear winner. Roles can change, be combined, and be added/removed dynamically—capabilities that inheritance cannot provide.\n\nThe Problem with Role Inheritance:\n\n- An object's role may change (employee becomes manager)\n- An object may have multiple simultaneous roles (user is also an admin)\n- Role combinations are arbitrary (not a fixed hierarchy)\n- Permissions/capabilities can be granted and revoked
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
// Role-Based Variations: User Permissions // BAD: Role hierarchy via inheritanceabstract class User { String name; abstract boolean canRead(); abstract boolean canWrite(); abstract boolean canDelete(); abstract boolean canAdmin();} class RegularUser extends User { boolean canRead() { return true; } boolean canWrite() { return false; } boolean canDelete() { return false; } boolean canAdmin() { return false; }} class Editor extends User { boolean canRead() { return true; } boolean canWrite() { return true; } boolean canDelete() { return false; } boolean canAdmin() { return false; }} class Admin extends User { boolean canRead() { return true; } boolean canWrite() { return true; } boolean canDelete() { return true; } boolean canAdmin() { return true; }} // Problems:// 1. What if someone needs read + delete but not write?// 2. What if a user's role changes? New object needed.// 3. What if someone is Editor on Project A, Admin on Project B?// 4. Adding a new permission requires modifying ALL classes. // GOOD: Composition with assigned permissionspublic enum Permission { READ, WRITE, DELETE, ADMIN, EXPORT, AUDIT, // extensible} public interface Role { String getName(); Set<Permission> getPermissions();} public class SimpleRole implements Role { private final String name; private final Set<Permission> permissions; public SimpleRole(String name, Permission... permissions) { this.name = name; this.permissions = EnumSet.copyOf(Arrays.asList(permissions)); } // Getters...} public class User { private final String name; private final Set<Role> roles = new HashSet<>(); private final Set<Permission> additionalPermissions = EnumSet.noneOf(Permission.class); public void addRole(Role role) { roles.add(role); } public void removeRole(Role role) { roles.remove(role); } public void grantPermission(Permission permission) { additionalPermissions.add(permission); } public void revokePermission(Permission permission) { additionalPermissions.remove(permission); } public boolean hasPermission(Permission permission) { // Check additional permissions first if (additionalPermissions.contains(permission)) { return true; } // Then check all roles return roles.stream() .anyMatch(role -> role.getPermissions().contains(permission)); } public Set<Permission> getAllPermissions() { Set<Permission> all = EnumSet.noneOf(Permission.class); all.addAll(additionalPermissions); for (Role role : roles) { all.addAll(role.getPermissions()); } return all; }} // Usage: Flexible, dynamic, combinableRole viewer = new SimpleRole("Viewer", Permission.READ);Role editor = new SimpleRole("Editor", Permission.READ, Permission.WRITE);Role admin = new SimpleRole("Admin", Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN); User alice = new User("Alice");alice.addRole(editor); // Alice is an editoralice.grantPermission(Permission.EXPORT); // Plus special export access // Later: Alice gets promotedalice.addRole(admin); // Same object, new permissions! // Context-specific rolesMap<Project, Set<Role>> projectRoles = new HashMap<>();projectRoles.put(projectA, Set.of(editor));projectRoles.put(projectB, Set.of(admin));The Capability Pattern:\n\nThis extends naturally to a capability pattern where objects acquire capabilities through composition:\n\n- Instead of "User extends Swimmer", User has a SwimmingCapability\n- Capabilities can be added, removed, upgraded\n- Multiple capabilities compose without hierarchy restrictions\n- Capabilities can be context-dependent (grants for specific resources)
Modern game engines use Entity-Component-System (ECS) architecture—pure composition. Entities (game objects) are just IDs that have Components (data) attached. Systems operate on entities with specific component combinations. This flexibility is impossible with inheritance hierarchies.
When you're working in a domain you don't fully understand yet or one that's rapidly evolving, composition provides essential flexibility. Inheritance locks in design assumptions that may prove wrong; composition allows incremental restructuring.\n\nThe Early-Stage Problem:\n\n- You don't yet know what the right abstractions are\n- Your understanding of "is-a" relationships is incomplete\n- Requirements are changing frequently\n- You're learning what varies and what stays constant\n\nInheritance crystallizes your current (incomplete) understanding into a rigid structure. Composition keeps options open.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// Evolving Domain: E-commerce Product Model // PHASE 1: "Products are simple, right?"// Initial inheritance approach seems fine at first... abstract class Product { constructor( public readonly id: string, public readonly name: string, public readonly price: number ) {}} class PhysicalProduct extends Product { constructor(id: string, name: string, price: number, public readonly weight: number, public readonly dimensions: Dimensions ) { super(id, name, price); }} class DigitalProduct extends Product { constructor(id: string, name: string, price: number, public readonly downloadUrl: string, public readonly fileSizeMb: number ) { super(id, name, price); }} // PHASE 2: "Wait, subscriptions are different..."// Now we need recurring billing. Where does it go? class SubscriptionProduct extends DigitalProduct { // Hmm, is this right? constructor(/* ... */, public readonly billingCycle: 'monthly' | 'annual' ) { super(/* ... */); }} // But wait, there are physical subscriptions too (magazines, boxes)!// Now what? PhysicalSubscription? We're getting a hierarchy explosion. // PHASE 3: "We need bundles, and variations, and..."// A bundle contains multiple products// A product can have size/color variations// Some products are rentals, not purchases// The hierarchy is collapsing under its own weight! // BETTER: Composition from the start interface Purchasable { calculatePrice(context: PriceContext): Money;} interface Deliverable { getDeliveryMethod(): DeliveryMethod; estimateDelivery(address: Address): Date;} interface Downloadable { getDownloadInfo(): DownloadInfo;} interface Subscribable { getBillingCycle(): BillingCycle; getNextBillingDate(): Date;} interface Bundleable { getComponents(): Product[];} // Product is composed of capabilitiesclass Product implements Purchasable { private readonly capabilities: Map<string, unknown> = new Map(); // Core identity constructor( public readonly id: string, public readonly name: string, private priceCalculator: PriceCalculator ) {} // Add capabilities dynamically addCapability<T>(key: string, capability: T): this { this.capabilities.set(key, capability); return this; } getCapability<T>(key: string): T | undefined { return this.capabilities.get(key) as T | undefined; } hasCapability(key: string): boolean { return this.capabilities.has(key); } calculatePrice(context: PriceContext): Money { return this.priceCalculator.calculate(this, context); }} // Build products by compositionconst physicalBook = new Product('book-1', 'Clean Code', basePriceCalculator) .addCapability('shipping', new ShippingInfo(weight: 0.5, dimensions)) .addCapability('inventory', new InventoryTracker(warehouse)); const ebook = new Product('ebook-1', 'Clean Code (Digital)', ebookPriceCalculator) .addCapability('download', new DownloadInfo(url, sizeMb)) .addCapability('drm', new DrmProtection(scheme)); const magazine = new Product('mag-1', 'Tech Monthly', subscriptionPriceCalculator) .addCapability('shipping', new ShippingInfo(weight: 0.2, dimensions)) .addCapability('subscription', new SubscriptionInfo('monthly')); const allAccessBundle = new Product('bundle-1', 'All Access', bundlePriceCalculator) .addCapability('bundle', new BundleInfo([physicalBook, ebook, magazine])) .addCapability('subscription', new SubscriptionInfo('annual')); // The domain evolved, our structure adapted without major refactoring!A common heuristic: wait until you see a pattern THREE times before abstracting. With composition, you can build concrete implementations first and extract common patterns later. With inheritance, you often abstractly incorrectly and then struggle to fix it.
We've examined seven distinct scenarios where composition is clearly the superior choice. Let's consolidate these into a recognition checklist:
| Scenario | Key Indicators | Pattern Often Used |
|---|---|---|
| Runtime Configuration | Behavior changes during execution | Strategy |
| Multiple Variation Dimensions | Combinatorial possibilities | Decorator, Builder |
| Cross-Cutting Concerns | Behavior applies across hierarchies | Decorator, Proxy |
| External Dependencies | Code you don't control | Wrapper, Adapter |
| Testability Priority | Need to mock/isolate components | Dependency Injection |
| Role-Based Variations | Roles change, combine, overlap | Capability Pattern |
| Uncertain Domain | Evolving requirements, learning phase | Flexible Composition |
What's Next:\n\nHaving explored scenarios favoring each approach, we'll next examine hybrid approaches that combine inheritance and composition effectively, getting the best of both worlds.
You can now recognize the scenarios where composition is clearly the right choice: runtime behavior configuration, multiple variation dimensions, cross-cutting concerns, external dependencies, testability requirements, role-based variations, and uncertain domains. Next, we'll explore hybrid approaches that combine both techniques.