Loading learning content...
The principle "favor composition over inheritance" has been so thoroughly absorbed into software engineering culture that it sometimes morphs into "avoid inheritance entirely." This overcorrection is a mistake.\n\nInheritance is a powerful language feature that, when used appropriately, creates elegant, maintainable designs. The key word is appropriately. Just as a surgeon doesn't avoid the scalpel—they use it precisely when it's the best instrument—experienced engineers recognize the scenarios where inheritance genuinely shines.\n\nThis page examines those scenarios in detail. You'll learn to recognize contexts where inheritance isn't just acceptable but is the superior choice, providing cleaner abstractions and more expressive designs than composition would.
By the end of this page, you will understand the specific scenarios where inheritance is genuinely appropriate. You'll recognize the characteristics that make these scenarios suitable for inheritance and understand why composition would be inferior in these contexts.
The most compelling use case for inheritance is when you're modeling a genuine type hierarchy that exists in your problem domain—where subtypes truly are specialized versions of a general type, not just implementations that share some code.\n\nCharacteristics of True Type Hierarchies:\n\n- The is-a relationship is fundamental to the domain, not an implementation convenience\n- Subtypes are fully substitutable for the supertype (Liskov Substitution Principle)\n- The hierarchy reflects real conceptual categories, not just code organization\n- Clients genuinely benefit from treating subtypes polymorphically\n- The categories are stable and unlikely to require frequent restructuring
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// True Type Hierarchy: AST Nodes // In a programming language, expressions form a genuine type hierarchy.// Every specific expression (literal, binary op, call) IS-A expression.// This isn't an implementation convenience—it's domain truth. public abstract class Expression { public abstract Object evaluate(Environment env); public abstract Type typeCheck(TypeEnvironment typeEnv); public abstract void accept(ExpressionVisitor visitor);} public class LiteralExpression extends Expression { private final Object value; public LiteralExpression(Object value) { this.value = value; } @Override public Object evaluate(Environment env) { return value; // Literals evaluate to themselves } @Override public Type typeCheck(TypeEnvironment typeEnv) { return Type.of(value.getClass()); } @Override public void accept(ExpressionVisitor visitor) { visitor.visitLiteral(this); }} public class BinaryExpression extends Expression { private final Expression left; private final Operator operator; private final Expression right; @Override public Object evaluate(Environment env) { Object leftValue = left.evaluate(env); Object rightValue = right.evaluate(env); return operator.apply(leftValue, rightValue); } @Override public Type typeCheck(TypeEnvironment typeEnv) { Type leftType = left.typeCheck(typeEnv); Type rightType = right.typeCheck(typeEnv); return operator.resultType(leftType, rightType); } @Override public void accept(ExpressionVisitor visitor) { visitor.visitBinary(this); }} public class FunctionCallExpression extends Expression { private final String functionName; private final List<Expression> arguments; @Override public Object evaluate(Environment env) { Function function = env.lookupFunction(functionName); List<Object> evaluatedArgs = arguments.stream() .map(arg -> arg.evaluate(env)) .toList(); return function.invoke(evaluatedArgs); } // ... other methods} // Client code benefits from polymorphismpublic class Compiler { public void compile(Expression expr) { // Can work with ANY expression type Type type = expr.typeCheck(typeEnvironment); expr.accept(codeGenerator); }}Why Inheritance Works Here:\n\n1. Domain Truth: Every BinaryExpression genuinely IS an Expression. This isn't a modeling decision—it's how programming languages work.\n\n2. Polymorphic Treatment Is Essential: Compilers and interpreters MUST treat all expressions uniformly. You need to evaluate, type-check, and transform expressions without knowing their specific type.\n\n3. Stable Categories: The categories of expressions in a language are well-defined and rarely change. Adding a new expression type is a deliberate language evolution.\n\n4. Substitutability: Any code expecting an Expression works correctly with any subtype. There's no behavioral mismatch.\n\n5. Composition Would Be Awkward: What would an Expression "have"? The inheritance hierarchy naturally expresses what expressions ARE, not what they HAVE.
Ask yourself: 'Would a domain expert, without programming knowledge, recognize this hierarchy as reflecting real categories in the problem domain?' If yes, inheritance may be expressing domain truth. If the hierarchy only makes sense to a programmer, question whether it's truly appropriate.
Many frameworks are designed with explicit extension points that expect you to inherit from provided base classes. In these cases, inheritance isn't a choice—it's the framework's contract.\n\nThe Template Method Pattern:\n\nFrameworks often use the Template Method pattern: the framework provides an abstract base class that implements the overall algorithm structure, with specific steps left as abstract methods for you to override.\n\n- The framework controls the algorithm skeleton\n- You customize specific steps through overriding\n- The framework calls your methods at appropriate times\n\nThis is inheritance by design, and it's the intended usage pattern.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Framework Extension Point: Servlet API // The Servlet API provides HttpServlet as an extension point// You MUST extend it to create a servlet - this is by framework design public class OrderServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // Your implementation String orderId = request.getParameter("id"); Order order = orderService.findById(orderId); response.setContentType("application/json"); response.getWriter().write(toJson(order)); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // Your implementation Order order = parseOrder(request); orderService.save(order); response.setStatus(HttpServletResponse.SC_CREATED); } // HttpServlet provides: service(), init(), destroy() // and calls doGet/doPost appropriately based on HTTP method @Override public void init() throws ServletException { // Optional: Custom initialization super.init(); this.orderService = ServiceLocator.getOrderService(); }} // The framework handles:// - Lifecycle management (init/destroy)// - Request routing to appropriate doXxx method// - Error handling and logging// - Thread safety for service() // You provide:// - Business logic for each HTTP method// - Custom initialization if neededWhy Inheritance Is Appropriate Here:\n\n1. Framework Design Intent: The framework authors designed these classes specifically to be extended. The abstract methods are intentional extension points.\n\n2. Inversion of Control: The framework controls execution flow and calls your methods. This is the essence of a framework as opposed to a library.\n\n3. Default Behavior: Base classes often provide useful defaults that you inherit automatically. HttpServlet handles request parsing, content type handling, error cases, etc.\n\n4. Type Integration: The framework's infrastructure expects types that extend the base class. Composition wouldn't satisfy the type requirements.\n\n5. Documentation and Convention: Framework documentation explains how to extend these base classes. This is the expected, supported pattern.
Note that modern frameworks increasingly offer composition-based alternatives. Spring MVC offers @Controller annotations instead of extending base classes. If your framework supports both, composition is often more flexible. Use inheritance when it's the framework's primary extension mechanism.
When you're defining an abstraction where multiple implementations share substantial common code, abstract classes with inheritance provide an elegant solution.\n\nThe Pattern:\n\n- Define an abstract base class with:\n - Abstract methods that implementations must provide\n - Concrete methods that provide shared behavior\n - Protected helper methods available to subclasses\n\n- Subclasses:\n - Implement the abstract methods with their specific behavior\n - Inherit the shared behavior without duplication\n - Can optionally override concrete methods if needed
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
// Abstract Contract: Message Queue Consumer // All queue consumers share significant common logic:// - Connection management// - Retry handling// - Error logging// - Metrics reporting// - Graceful shutdown // The variation is in message processing, which is domain-specific abstract class MessageQueueConsumer<T> { private connection: QueueConnection; private isRunning: boolean = false; private metrics: ConsumerMetrics; constructor( protected readonly queueName: string, protected readonly config: ConsumerConfig ) { this.metrics = new ConsumerMetrics(queueName); } // Template Method: The overall consume algorithm async start(): Promise<void> { this.isRunning = true; this.connection = await this.createConnection(); console.log(`Consumer started for queue: ${this.queueName}`); while (this.isRunning) { try { const message = await this.receiveMessage(); if (message) { await this.handleWithRetry(message); } } catch (error) { this.handleError(error); } } } // ABSTRACT: Subclasses provide message processing logic protected abstract processMessage(message: T): Promise<void>; // ABSTRACT: Subclasses define how to deserialize messages protected abstract deserialize(raw: string): T; // Shared: Retry logic used by all consumers private async handleWithRetry(rawMessage: RawMessage): Promise<void> { const message = this.deserialize(rawMessage.body); let attempts = 0; while (attempts < this.config.maxRetries) { try { const startTime = Date.now(); await this.processMessage(message); this.metrics.recordSuccess(Date.now() - startTime); await this.acknowledge(rawMessage); return; } catch (error) { attempts++; this.metrics.recordRetry(); if (attempts < this.config.maxRetries) { await this.delay(this.calculateBackoff(attempts)); } } } // All retries exhausted this.metrics.recordFailure(); await this.moveToDeadLetterQueue(rawMessage); } // Protected: Available to subclasses for custom logging protected log(level: LogLevel, message: string, context?: object): void { Logger.log(level, `[${this.queueName}] ${message}`, context); } // Shared: Graceful shutdown async stop(): Promise<void> { this.isRunning = false; await this.connection.close(); console.log(`Consumer stopped for queue: ${this.queueName}`); } // ... other shared methods} // Concrete implementation for Order messagesclass OrderQueueConsumer extends MessageQueueConsumer<OrderMessage> { constructor( private readonly orderService: OrderService ) { super('orders', { maxRetries: 3, visibilityTimeout: 30 }); } protected deserialize(raw: string): OrderMessage { return JSON.parse(raw) as OrderMessage; } protected async processMessage(message: OrderMessage): Promise<void> { this.log('info', `Processing order: ${message.orderId}`); switch (message.type) { case 'created': await this.orderService.handleOrderCreated(message); break; case 'updated': await this.orderService.handleOrderUpdated(message); break; case 'cancelled': await this.orderService.handleOrderCancelled(message); break; } }} // Another implementation for Payment messagesclass PaymentQueueConsumer extends MessageQueueConsumer<PaymentMessage> { constructor( private readonly paymentService: PaymentService, private readonly notificationService: NotificationService ) { super('payments', { maxRetries: 5, visibilityTimeout: 60 }); } protected deserialize(raw: string): PaymentMessage { return PaymentMessage.fromJson(raw); // Custom deserialization } protected async processMessage(message: PaymentMessage): Promise<void> { await this.paymentService.process(message); await this.notificationService.sendPaymentConfirmation(message); }}Why Inheritance Is Appropriate Here:\n\n1. Substantial Shared Logic: All consumers need connection management, retry handling, metrics, logging, and shutdown. This isn't trivial code to duplicate.\n\n2. Variation Is Well-Defined: The variation points are clear—message deserialization and processing. Abstract methods precisely define what must vary.\n\n3. Template Method Pattern: The overall algorithm (start, receive, process, retry, acknowledge) is consistent. Only specific steps vary.\n\n4. You Control the Base Class: You're defining the abstraction, so you can evolve it carefully, documenting the contract for subclasses.\n\n5. Alternative Would Be Complex: With composition, you'd need multiple interfaces (Deserializer, Processor, etc.) plus a coordinator. For this amount of shared logic, inheritance is simpler.
In languages like Java 8+, interfaces can have default methods. However, abstract classes remain preferable when you need state (instance variables) or when the shared implementation is the primary purpose. Interfaces with defaults are better when you need multiple inheritance and the defaults are convenience helpers.
Inheritance works particularly well for immutable value types that form a natural hierarchy. When objects are immutable, many of inheritance's problematic aspects are mitigated.\n\nWhy Immutability Helps:\n\n- No fragile base class problem: Without mutable state, parent methods can't be broken by state changes in children\n- Safe polymorphism: Immutable subtypes are naturally substitutable—they can't violate invariants through mutation\n- Thread safety: Immutable hierarchies are inherently thread-safe\n- Simplified reasoning: You can reason about types without considering state transitions
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
// Immutable Value Hierarchy: Time Durations // All durations are immutable.// The hierarchy naturally expresses precision/unit relationships. public abstract class Duration implements Comparable<Duration> { // Core operation - all durations can convert to milliseconds public abstract long toMilliseconds(); // Shared operations based on core abstraction public final long toSeconds() { return toMilliseconds() / 1000; } public final long toMinutes() { return toSeconds() / 60; } public final Duration plus(Duration other) { return Milliseconds.of(this.toMilliseconds() + other.toMilliseconds()); } public final Duration multiply(int factor) { return Milliseconds.of(this.toMilliseconds() * factor); } @Override public final int compareTo(Duration other) { return Long.compare(this.toMilliseconds(), other.toMilliseconds()); } @Override public final boolean equals(Object obj) { if (obj instanceof Duration) { return this.toMilliseconds() == ((Duration) obj).toMilliseconds(); } return false; } @Override public final int hashCode() { return Long.hashCode(toMilliseconds()); }} // Concrete immutable duration typespublic final class Milliseconds extends Duration { private final long value; private Milliseconds(long value) { this.value = value; } public static Milliseconds of(long value) { return new Milliseconds(value); } @Override public long toMilliseconds() { return value; } @Override public String toString() { return value + "ms"; }} public final class Seconds extends Duration { private final long value; private Seconds(long value) { this.value = value; } public static Seconds of(long value) { return new Seconds(value); } @Override public long toMilliseconds() { return value * 1000; } @Override public String toString() { return value + "s"; }} public final class Minutes extends Duration { private final long value; private Minutes(long value) { this.value = value; } public static Minutes of(long value) { return new Minutes(value); } @Override public long toMilliseconds() { return value * 60 * 1000; } @Override public String toString() { return value + "m"; }} // Usage - polymorphic, safe, expressiveDuration timeout = Seconds.of(30);Duration retryDelay = Milliseconds.of(500);Duration maxWait = Minutes.of(5); // All can be compared, added, etc.if (timeout.compareTo(maxWait) < 0) { Duration remaining = maxWait.plus(retryDelay);}Characteristics of Good Immutable Hierarchies:\n\n1. All fields are final: No mutable state anywhere in the hierarchy\n2. All subclasses are also immutable: Immutability is enforced at every level\n3. Methods return new instances: Operations like plus() return new objects rather than modifying existing ones\n4. Final classes at leaves: Concrete types are final to prevent extension that might introduce mutability\n5. Meaningful polymorphism: The base type provides operations that make sense for all subtypes
Modern languages (Java 17+ sealed classes, Kotlin sealed classes, TypeScript discriminated unions) support sealed hierarchies—inheritance where all subtypes are known at compile time. This is ideal for immutable value types: you get inheritance's expressiveness with exhaustive pattern matching.
Inheritance works well when you're building a closed hierarchy—one where you control all possible subtypes and explicitly limit extensibility. Modern languages increasingly support this through sealed/closed class mechanisms.\n\nThe Closed Hierarchy Pattern:\n\n- A base type with a fixed set of known subtypes\n- External code cannot add new subtypes\n- Pattern matching/exhaustive switching is possible\n- The compiler can verify all cases are handled\n\nThis pattern is particularly powerful for modeling algebraic data types and state machines.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Sealed Hierarchy: Operation Result (Java 17+) // The Result type has exactly two subtypes - success or failure// External code cannot add new Result types public sealed interface Result<T, E> permits Success, Failure { // Common operations boolean isSuccess(); T getOrThrow() throws E; <U> Result<U, E> map(Function<T, U> mapper); <U> Result<U, E> flatMap(Function<T, Result<U, E>> mapper); T orElse(T defaultValue);} public final class Success<T, E> implements Result<T, E> { private final T value; public Success(T value) { this.value = value; } @Override public boolean isSuccess() { return true; } @Override public T getOrThrow() { return value; } @Override public <U> Result<U, E> map(Function<T, U> mapper) { return new Success<>(mapper.apply(value)); } @Override public <U> Result<U, E> flatMap(Function<T, Result<U, E>> mapper) { return mapper.apply(value); } @Override public T orElse(T defaultValue) { return value; }} public final class Failure<T, E> implements Result<T, E> { private final E error; public Failure(E error) { this.error = error; } @Override public boolean isSuccess() { return false; } @Override public T getOrThrow() throws E { throw error; } @Override public <U> Result<U, E> map(Function<T, U> mapper) { return (Result<U, E>) this; // Propagate failure } @Override public <U> Result<U, E> flatMap(Function<T, Result<U, E>> mapper) { return (Result<U, E>) this; // Propagate failure } @Override public T orElse(T defaultValue) { return defaultValue; } public E getError() { return error; }} // Usage with pattern matching (Java 21+)Result<User, DatabaseError> result = userRepository.findById(id); String message = switch (result) { case Success<User, DatabaseError> s -> "Found: " + s.getOrThrow().getName(); case Failure<User, DatabaseError> f -> "Error: " + f.getError().getMessage(); // Compiler ensures all cases are covered!};Benefits of Sealed Hierarchies:\n\n1. Exhaustiveness checking: The compiler verifies you handle all cases. Adding a new subtype creates compile errors everywhere it's not handled—a feature, not a bug.\n\n2. Documentation: The sealed declaration documents exactly what subtypes exist. No need to search the codebase.\n\n3. Safe refactoring: Removing or renaming a subtype triggers compile errors. The type system prevents accidents.\n\n4. Pattern matching: Works naturally with switch/when expressions for clean, type-safe handling.\n\n5. Evolution control: New subtypes require modifying the sealed parent, making hierarchy changes deliberate.
Java 17+ has 'sealed' classes/interfaces. Kotlin has 'sealed' classes. Scala 3 has enum/sealed traits. TypeScript achieves similar effects through discriminated unions. If your language supports sealed types, they're an excellent use case for inheritance.
Sometimes inheritance is appropriate when you need specialized implementations that optimize for specific cases while maintaining a common interface. The subtype provides the same behavior but with better performance for its specific context.\n\nThe Pattern:\n\n- A general base class implements an algorithm that works for all cases\n- Subclasses override with optimized implementations for specific scenarios\n- Clients use the base type, getting optimization transparently\n- Substitutability is perfect—only performance differs, not semantics
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// Behavioral Specialization: Collection Implementations // The Collections Framework uses inheritance for specialized implementations// All provide the same List behavior, optimized for different use cases public abstract class AbstractList<E> implements List<E> { // General implementation using iterator @Override public boolean contains(Object o) { for (E element : this) { if (Objects.equals(element, o)) { return true; } } return false; } @Override public int indexOf(Object o) { int index = 0; for (E element : this) { if (Objects.equals(element, o)) { return index; } index++; } return -1; } // ... many other default implementations} public class ArrayList<E> extends AbstractList<E> { private Object[] elements; // Optimized: O(1) random access @Override public E get(int index) { rangeCheck(index); return (E) elements[index]; } // Inherits contains() from AbstractList - O(n), good enough // Could override for optimization but semantics identical} public class LinkedList<E> extends AbstractList<E> { private Node<E> head; private Node<E> tail; // Different optimization: O(1) add to ends @Override public void addFirst(E element) { Node<E> newNode = new Node<>(element, null, head); if (head != null) { head.prev = newNode; } head = newNode; if (tail == null) { tail = newNode; } } // get(int) is O(n) - structural trade-off, same semantics @Override public E get(int index) { return node(index).element; // Traverses from head or tail }} // Specialized immutable view - same semantics, optimized for casepublic class SingletonList<E> extends AbstractList<E> { private final E element; @Override public int size() { return 1; } @Override public E get(int index) { if (index != 0) throw new IndexOutOfBoundsException(); return element; } @Override public boolean contains(Object o) { return Objects.equals(element, o); // O(1)! }} // Client code works with any List - gets optimizations transparentlyvoid processItems(List<String> items) { if (items.contains("special")) { // Uses specialized or general impl // ... }}Why This Works:\n\n1. Semantics Are Identical: All implementations provide exactly the same behavior. Only performance characteristics differ.\n\n2. Perfect Substitutability: Any code using the base type works correctly with any subtype. LSP is satisfied.\n\n3. Transparent Optimization: Clients don't need to know which implementation they have. The right implementation is selected at construction time.\n\n4. Shared Defaults: The base class provides reasonable default implementations. Subclasses only override where they can do better.\n\n5. Well-Understood Pattern: This is how the Java Collections Framework works—it's a proven, successful application of inheritance.
This pattern ONLY works when overriding methods provide semantically equivalent behavior. If a subclass changes WHAT a method does (not just HOW), you've violated LSP and should reconsider the design. Optimization inheritance requires that all implementations produce the same results—just at different speeds.
We've examined six distinct scenarios where inheritance is genuinely appropriate. Let's consolidate these into a recognition checklist:
| Scenario | Key Indicators | Example |
|---|---|---|
| True Type Hierarchies | Domain-rooted is-a, polymorphic treatment needed | AST nodes, geometric shapes |
| Framework Extension Points | Framework expects extension, Template Method pattern | HttpServlet, Activity |
| Abstract Contracts + Shared Impl | Substantial common code, well-defined variation points | Queue consumers, validators |
| Immutable Value Hierarchies | Immutable types, operations return new instances | Duration types, Result types |
| Sealed Hierarchies | Fixed set of subtypes, exhaustive handling needed | State machines, algebraic data types |
| Behavioral Specialization | Same semantics, different performance characteristics | Collection implementations |
What's Next:\n\nHaving explored when inheritance is appropriate, we'll next examine the scenarios where composition is clearly the superior choice—contexts where reaching for inheritance would create problems.
You can now recognize the scenarios where inheritance is genuinely appropriate: true type hierarchies, framework extension points, abstract contracts with shared implementation, immutable value hierarchies, sealed/closed hierarchies, and behavioral specialization for optimization. Next, we'll explore composition-appropriate scenarios.