Difference Between Inheritance and Interface in Java (Practical, 2026-Ready)

I keep running into the same bug pattern in code reviews: a class hierarchy that looked elegant on day one becomes rigid by month three. A new feature arrives, and suddenly a subclass has to pretend it is something it isn’t. That’s the moment when the difference between inheritance and interfaces stops being academic and starts hurting delivery speed. I’ve been on both sides—overusing inheritance early, then scrambling to untangle it later. If you’re building Java systems in 2026, this topic still matters because teams move fast, requirements shift, and you need designs that hold up when the first “just add one more variant” request comes in.

In the next sections, I’ll explain what inheritance and interfaces really buy you, where each one shines, and the tradeoffs that show up in production code. I’ll also share the heuristics I use now, the mistakes I see juniors and seniors alike make, and a complete, runnable example that mixes both approaches in a clean, scalable way. By the end, you should be able to look at a new problem and decide, confidently and quickly, which tool fits.

The problem I keep seeing in code reviews

When I review Java code, the most common design smell is “inheritance for convenience.” Someone wants shared logic, so they build a base class. It works, until a second variation arrives and the base class has to grow conditionals, flags, or methods that only apply to one subclass. That’s when the model starts lying: subclasses inherit behavior that doesn’t fit their identity. The opposite also happens—an interface is chosen for everything, even when shared state or lifecycle behavior belongs in a base class. The result is a patchwork of duplicated fields and boilerplate, or interfaces stuffed with default methods that behave like a class hierarchy in disguise.

Here’s the mental model I use. Inheritance is about “is-a” relationships where shared state and shared behavior are stable and meaningful. Interfaces are about “can-do” capabilities where multiple unrelated types should be treated the same way. Both are forms of abstraction, but they solve different problems. If you confuse them, your class design becomes a trap. If you use them together intentionally, your codebase becomes easier to extend and easier to reason about.

Inheritance in Java: power and constraints

Inheritance lets one class inherit fields and methods from another class. It is a direct line of shared state and behavior: the subclass is a specialized version of the superclass. When that relationship is real and stable, inheritance is efficient and expressive. When it isn’t, inheritance becomes a source of accidental coupling.

I tell teams to treat inheritance as a commitment, not just a shortcut. A subclass inherits every non-private field and method, for better or worse. That can be useful when you have a core identity that all subclasses should share. For example, a base Vehicle class might define vin, manufacturedAt, and getAgeInYears(). Every vehicle has those fields, and every vehicle should behave consistently. Inheritance is a clean fit.

Here’s a small, runnable example showing a meaningful inheritance relationship:

package demo;

import java.time.LocalDate;

import java.time.Period;

abstract class Vehicle {

private final String vin;

private final LocalDate manufacturedAt;

protected Vehicle(String vin, LocalDate manufacturedAt) {

this.vin = vin;

this.manufacturedAt = manufacturedAt;

}

public String getVin() {

return vin;

}

public int getAgeInYears() {

return Period.between(manufacturedAt, LocalDate.now()).getYears();

}

public abstract int getWheelCount();

}

class Motorcycle extends Vehicle {

public Motorcycle(String vin, LocalDate manufacturedAt) {

super(vin, manufacturedAt);

}

@Override

public int getWheelCount() {

return 2;

}

}

class Truck extends Vehicle {

public Truck(String vin, LocalDate manufacturedAt) {

super(vin, manufacturedAt);

}

@Override

public int getWheelCount() {

return 6;

}

}

This design is simple: every vehicle has a VIN and manufacture date, and the only variation is wheel count. I’m comfortable with inheritance here because the shared state is real and the behavior is stable.

Now the constraints. Java does not support multiple inheritance of classes. A class can only extend one class. That’s a deliberate design choice that avoids the “diamond problem,” but it also means you can’t inherit from two base classes even if it seems tempting. That limitation forces careful modeling. If you find yourself wishing a class could extend two base classes, that’s a signal to use interfaces and composition instead.

Inheritance also introduces tight coupling. A change in the base class can ripple to every subclass. This is not inherently bad, but it’s a responsibility. I prefer inheritance when the base class is relatively stable, or when I want to enforce a strict lifecycle (template methods, enforced initialization, or invariant checks).

Interfaces in Java: contracts, polymorphism, and evolution

An interface defines a contract. It tells you what methods a class promises to provide, without dictating the internal state or the specific implementation. This is ideal when you care about a capability, not a concrete type. In real systems, that’s common: “Can this object be serialized?” “Can it be priced?” “Can it be audited?”

The real strength of interfaces is that a class can implement many of them. That gives you multiple inheritance of type, which is often what you want: an object can act as many things at once. For example, a Payment object might be Billable, Refundable, and Auditable. None of those require shared state, just shared method signatures.

Interfaces can now include default and static methods. That makes interfaces more flexible for evolution: you can add a method without breaking every implementation by providing a default. I still use this carefully. Default methods can be helpful for shared behavior that fits every implementer, but they can also hide complexity if overused.

Here’s a small example that shows the contract focus:

package demo;

import java.math.BigDecimal;

interface PricedItem {

BigDecimal price();

}

interface Discountable {

BigDecimal discount();

default BigDecimal priceAfterDiscount(BigDecimal originalPrice) {

// Default behavior is shared, but implementations can override if needed

return originalPrice.subtract(discount());

}

}

class Subscription implements PricedItem, Discountable {

private final BigDecimal monthlyFee;

private final BigDecimal promoDiscount;

public Subscription(BigDecimal monthlyFee, BigDecimal promoDiscount) {

this.monthlyFee = monthlyFee;

this.promoDiscount = promoDiscount;

}

@Override

public BigDecimal price() {

return monthlyFee;

}

@Override

public BigDecimal discount() {

return promoDiscount;

}

}

This design gives Subscription two capabilities without forcing it into a rigid inheritance chain. I can add another class like PhysicalProduct that also implements these interfaces without sharing any internal data structure.

Interfaces also support loose coupling. If your code depends on interfaces instead of concrete classes, you can replace implementations easily, test with fakes, and evolve systems without rewriting core logic. That’s why modern Java architectures, especially service-oriented systems, prefer interface-driven design.

Side-by-side differences that matter in practice

Here is a focused comparison that I keep in mind when modeling a new domain. This goes beyond syntax and focuses on the practical effects on design and maintenance.

Category

Inheritance

Interface —

— Core idea

Shared state + shared behavior across a strict “is-a” line

Shared capability across unrelated types Keyword

extends

implements Multiple inheritance

Not allowed for classes

Allowed for interfaces Shared fields

Yes, via base class

No (except constants) Default behavior

Yes, in base class methods

Yes, via default methods (use carefully) Coupling level

Tight: base class changes impact all subclasses

Loose: implementations can evolve independently Best when

Identity and behavior are stable and aligned

You need a contract across many types Common risk

Rigid hierarchies and fragile base classes

Interface sprawl or default-method abuse

I also pay attention to method design. Inheritance is great for template methods and common lifecycle steps. Interfaces are great for capability-based polymorphism and testability. When I see a class with conditional branches in a base method that depend on subclass identity, I consider that a warning sign: it’s a hint that the model is wrong or that composition would be better.

Decision framework I use in modern Java

Over the years I’ve built a small checklist I run through when deciding between inheritance and interfaces. I’ll share it in plain language, then show how it maps to “traditional vs modern” guidance.

1) If there is shared state that must be consistent and validated, I start with a base class. It’s the most direct way to enforce invariants.

2) If multiple unrelated types need the same capability, I use interfaces. That keeps the model honest and allows multiple roles without contorting a class hierarchy.

3) If I foresee multiple axes of variation, I avoid deep inheritance and use composition plus interfaces. Multiple inheritance of behavior doesn’t exist, and deep hierarchies are hard to maintain.

4) If I need backward compatibility for existing implementations, I prefer default methods in interfaces to avoid breaking changes. I keep default methods small and obviously safe.

Here’s a traditional vs modern comparison that maps to how teams think today:

Design choice

Traditional approach

Modern approach (2026) —

— Code reuse

Base class with inherited behavior

Small interfaces + composition, base class only for stable identity Extensibility

Add subclasses and override methods

Add new implementations without altering existing hierarchies Testing

Subclass to override behavior in tests

Interface-driven tests with fakes or mocks Evolution

Change base class carefully; expect ripple effects

Add new interface methods with defaults; avoid breaking changes Team workflow

Centralized design by one owner

Modular ownership, multiple teams implement same interface

The modern approach does not reject inheritance; it simply treats it as a specialized tool. I still use base classes for strong domain identity, shared validation, and lifecycle enforcement. I just avoid using inheritance as the first reflex.

A full example: a shipping domain with both tools

Let’s bring this to life with a practical, runnable example. Imagine you’re building a shipping system. Packages are physical items with shared state, but they also need capabilities like pricing and tracking. This is a perfect scenario for a base class plus interfaces.

package demo;

import java.math.BigDecimal;

import java.time.LocalDateTime;

import java.util.UUID;

interface Trackable {

String trackingId();

LocalDateTime lastScannedAt();

}

interface Billable {

BigDecimal calculatePrice();

}

abstract class Package {

private final String id;

private final double weightKg;

private final LocalDateTime createdAt;

protected Package(double weightKg) {

this.id = UUID.randomUUID().toString();

this.weightKg = weightKg;

this.createdAt = LocalDateTime.now();

}

public String getId() {

return id;

}

public double getWeightKg() {

return weightKg;

}

public LocalDateTime getCreatedAt() {

return createdAt;

}

// Template method: every package must declare its handling category

public abstract String handlingCategory();

}

class StandardPackage extends Package implements Trackable, Billable {

private final String trackingId;

private LocalDateTime lastScannedAt;

public StandardPackage(double weightKg) {

super(weightKg);

this.trackingId = "STD-" + UUID.randomUUID();

this.lastScannedAt = LocalDateTime.now();

}

@Override

public String handlingCategory() {

return "STANDARD";

}

@Override

public String trackingId() {

return trackingId;

}

@Override

public LocalDateTime lastScannedAt() {

return lastScannedAt;

}

public void scan() {

// Update scan time when a package is scanned

this.lastScannedAt = LocalDateTime.now();

}

@Override

public BigDecimal calculatePrice() {

// Simple pricing rule based on weight

return BigDecimal.valueOf(getWeightKg() * 3.50);

}

}

class RefrigeratedPackage extends Package implements Trackable, Billable {

private final String trackingId;

private LocalDateTime lastScannedAt;

private final double minTemperatureC;

public RefrigeratedPackage(double weightKg, double minTemperatureC) {

super(weightKg);

this.trackingId = "REF-" + UUID.randomUUID();

this.lastScannedAt = LocalDateTime.now();

this.minTemperatureC = minTemperatureC;

}

@Override

public String handlingCategory() {

return "REFRIGERATED";

}

public double getMinTemperatureC() {

return minTemperatureC;

}

@Override

public String trackingId() {

return trackingId;

}

@Override

public LocalDateTime lastScannedAt() {

return lastScannedAt;

}

public void scan() {

// Update scan time when a package is scanned

this.lastScannedAt = LocalDateTime.now();

}

@Override

public BigDecimal calculatePrice() {

// Refrigerated handling costs more

return BigDecimal.valueOf(getWeightKg() * 5.25);

}

}

This approach uses inheritance for shared package identity (id, weight, creation time) and interfaces for cross-cutting capabilities. If later you add a DigitalDelivery class that is billable but not trackable or physical, you can implement Billable without forcing it to extend Package.

Notice a few design choices:

  • Package is abstract and enforces a shared identity.
  • Trackable and Billable are pure contracts; they do not assume any fields.
  • Each class owns its data and behavior, keeping the model honest.

When I see systems that mix these tools cleanly, I see fewer breakages and faster onboarding for new developers.

Common mistakes, performance notes, and edge cases

I’ve made every one of these mistakes at least once, so I call them out explicitly:

Mistake 1: Using inheritance for code reuse only. If the only reason you are extending a class is to reuse a few methods, you are building tight coupling for a shallow gain. In practice, that code reuse becomes a tax: a change to the base class requires retesting every subclass, even those that don’t conceptually share the same identity. The fix is simple: extract the shared behavior into a small helper or a composition-based component, then inject it where needed.

Mistake 2: Making “god” base classes. The classic example is a BaseEntity that grows over time with id, createdAt, updatedAt, tenantId, auditLog, softDelete, and dozens more properties. Eventually, you have a class that represents everything and therefore represents nothing. When every domain object inherits dozens of fields, the model becomes ambiguous and violates single responsibility. The fix is to keep base classes tight and focused, and to use dedicated value objects for shared concepts like audit trails or soft delete.

Mistake 3: Treating interfaces as if they were base classes. You can add default methods, yes, but when you do that for many methods, you are recreating an inheritance chain without shared state. The code becomes confusing because the interface reads like a concrete base class, but it lacks the right place to store or validate state. The fix is to reserve default methods for very small, obviously safe behaviors, and keep complex shared behavior in separate classes.

Mistake 4: Overriding in ways that break contracts. The Liskov Substitution Principle matters here. If a subclass changes the meaning of a base method—say getWheelCount() returns 0 because “it’s a hover vehicle now”—the design is no longer a clean is-a relationship. Interfaces are safer for these shifting capabilities, but even there, you should avoid surprising behavior. The fix is to model the new identity explicitly instead of hacking the existing hierarchy.

Mistake 5: Skipping tests around inherited behavior. Inheritance multiplies the number of paths you need to test. A base class method might look simple, but if subclasses override parts of it or rely on subtle assumptions, it becomes a risk. I like to test base classes directly with “test doubles” or minimal subclasses, then separately test each subclass. With interfaces, I often test the contract in a shared test suite and apply it to each implementation.

Performance notes (and why they rarely decide the design)

The performance differences between inheritance and interfaces are usually minor in modern JVMs. Virtual dispatch exists in both cases. The JIT is very good at optimizing frequently used paths and devirtualizing where it can. I’ve seen code where interface-based polymorphism was as fast as direct calls after warm-up, and class inheritance performed within the same range. You may see small differences, often within a low single-digit percentage, but this should almost never be your decision criterion.

What does matter is how your design affects scalability and parallel development. If you use inheritance incorrectly, you end up with large, risky refactors. That costs far more than any micro-optimization. If you use interfaces and composition well, you can add new behavior in parallel without touching core code paths. In a team setting, that’s a bigger performance win than any dispatch detail.

Edge cases and design pressure points

  • Serialization frameworks: Some libraries want a no-arg constructor or require concrete classes. If your design is interface-heavy, you may need adapters or factories.
  • Reflection-based frameworks: They sometimes break if you hide fields in base classes or use proxies around interfaces. Be explicit about the types your framework expects.
  • Entity identity in persistence: If you use inheritance with JPA or similar tools, be careful about single-table vs joined inheritance strategies. Interfaces alone won’t help here; you need a strategy that matches your storage model.
  • Binary compatibility: Adding methods to interfaces can break older binaries unless you provide defaults. Adding methods to base classes can also break subclass compilation if abstract. Design for evolution intentionally.

Practical scenarios: when to use vs when not to use

I’ve found that most design disputes vanish when you anchor them in real scenarios. Here are practical cases I use in reviews.

Good fits for inheritance

  • Shared identity with strict invariants: Account, AccountWithOverdraft, AccountWithRewards. All are accounts, all must maintain balance and limits.
  • Lifecycle enforcement: A Job base class that defines execute() and enforces pre/post conditions. Subclasses fill in a single step via template method.
  • Domain rules that must be consistent: A Document base class that always validates metadata before saving, and subclasses only define storage formats.

Bad fits for inheritance (use interfaces or composition instead)

  • Multiple dimensions of variation: Report that can be HTML/CSV/PDF and also internal/external. That’s two axes. Use composition and interfaces instead of double inheritance.
  • Shared utilities only: A BaseStringUtils class is not a domain identity; use static helpers or services.
  • Evolving identity: If the domain can change in unpredictable ways (think payment methods or integrations), prefer interfaces.

Good fits for interfaces

  • Cross-cutting capability: Loggable, Auditable, Retryable, Schedulable.
  • Integration boundaries: Use interfaces to represent contracts across modules or teams. It allows independent evolution.
  • Testing boundaries: Interfaces make it trivial to swap real implementations with fakes or mocks.

Bad fits for interfaces

  • Stable shared state: If every implementer will need the same fields and validation logic, you’ll end up duplicating code. That belongs in a base class.
  • Large default behavior: If the interface needs 6 default methods to be “usable,” you probably need an abstract class instead.

Deep dive: composition as the bridge between the two

One of the best ways to avoid inheritance traps is to think in terms of composition. You can still use inheritance or interfaces, but composition keeps your design flexible.

Here’s a practical example: pricing rules. Instead of embedding pricing logic in a base class or default interface method, you can compose it with a strategy:

package demo;

import java.math.BigDecimal;

interface PricingStrategy {

BigDecimal priceFor(double weightKg);

}

class StandardPricing implements PricingStrategy {

@Override

public BigDecimal priceFor(double weightKg) {

return BigDecimal.valueOf(weightKg * 3.50);

}

}

class RefrigeratedPricing implements PricingStrategy {

@Override

public BigDecimal priceFor(double weightKg) {

return BigDecimal.valueOf(weightKg * 5.25);

}

}

abstract class PackageBase {

private final double weightKg;

private final PricingStrategy pricingStrategy;

protected PackageBase(double weightKg, PricingStrategy pricingStrategy) {

this.weightKg = weightKg;

this.pricingStrategy = pricingStrategy;

}

public double getWeightKg() {

return weightKg;

}

public BigDecimal calculatePrice() {

return pricingStrategy.priceFor(weightKg);

}

}

Now you can keep a clean base class for identity and lifecycle while delegating variable behavior to composed strategies. This reduces the need for deep inheritance and keeps interfaces focused.

Interface evolution: default methods as a tool, not a crutch

Default methods were a big shift in Java. They allow you to extend interfaces without breaking existing implementations. But they come with tradeoffs.

When I add a default method, I ask three questions:

1) Is this behavior safe and correct for every implementer?

2) Is it trivial enough that no one would need to override it immediately?

3) Would a utility class or helper method be clearer?

If I answer “no” to any of them, I avoid default methods.

Here’s a safe use of a default method:

interface Trackable {

String trackingId();

default String trackingUrl() {

return "https://tracking.example.com/" + trackingId();

}

}

This is safe because it is derived from a required method and doesn’t assume hidden state. Contrast that with a default method that tries to compute prices or validate objects—it could be wrong for many implementers.

A more complete, runnable example with mixed approaches

Let’s build a slightly more complete example that includes:

  • A base class for shared identity
  • Interfaces for capabilities
  • Composition for variability
  • Tests as a contract check
package demo;

import java.math.BigDecimal;

import java.time.LocalDateTime;

import java.util.UUID;

interface Billable {

BigDecimal price();

}

interface Trackable {

String trackingId();

LocalDateTime lastScannedAt();

void scan();

}

interface Hazardous {

String hazardClass();

}

interface PricingStrategy {

BigDecimal priceFor(double weightKg);

}

class StandardPricing implements PricingStrategy {

@Override

public BigDecimal priceFor(double weightKg) {

return BigDecimal.valueOf(weightKg * 3.50);

}

}

class HazardousPricing implements PricingStrategy {

@Override

public BigDecimal priceFor(double weightKg) {

return BigDecimal.valueOf(weightKg * 7.00);

}

}

abstract class PackageBase {

private final String id;

private final double weightKg;

private final LocalDateTime createdAt;

private final PricingStrategy pricingStrategy;

protected PackageBase(double weightKg, PricingStrategy pricingStrategy) {

if (weightKg <= 0) {

throw new IllegalArgumentException("weight must be positive");

}

this.id = UUID.randomUUID().toString();

this.weightKg = weightKg;

this.createdAt = LocalDateTime.now();

this.pricingStrategy = pricingStrategy;

}

public String getId() {

return id;

}

public double getWeightKg() {

return weightKg;

}

public LocalDateTime getCreatedAt() {

return createdAt;

}

public BigDecimal price() {

return pricingStrategy.priceFor(weightKg);

}

public abstract String handlingCategory();

}

class StandardPackage extends PackageBase implements Billable, Trackable {

private final String trackingId;

private LocalDateTime lastScannedAt;

public StandardPackage(double weightKg) {

super(weightKg, new StandardPricing());

this.trackingId = "STD-" + UUID.randomUUID();

this.lastScannedAt = LocalDateTime.now();

}

@Override

public String handlingCategory() {

return "STANDARD";

}

@Override

public BigDecimal price() {

return super.price();

}

@Override

public String trackingId() {

return trackingId;

}

@Override

public LocalDateTime lastScannedAt() {

return lastScannedAt;

}

@Override

public void scan() {

this.lastScannedAt = LocalDateTime.now();

}

}

class HazardousPackage extends PackageBase implements Billable, Trackable, Hazardous {

private final String trackingId;

private LocalDateTime lastScannedAt;

private final String hazardClass;

public HazardousPackage(double weightKg, String hazardClass) {

super(weightKg, new HazardousPricing());

this.trackingId = "HZD-" + UUID.randomUUID();

this.lastScannedAt = LocalDateTime.now();

this.hazardClass = hazardClass;

}

@Override

public String handlingCategory() {

return "HAZARDOUS";

}

@Override

public BigDecimal price() {

return super.price();

}

@Override

public String trackingId() {

return trackingId;

}

@Override

public LocalDateTime lastScannedAt() {

return lastScannedAt;

}

@Override

public void scan() {

this.lastScannedAt = LocalDateTime.now();

}

@Override

public String hazardClass() {

return hazardClass;

}

}

This example is a bit longer, but it shows a real-world structure. The base class enforces invariants and identity. Interfaces define capabilities. The pricing strategy is composed rather than inherited. The result is flexible: you can add a DigitalDelivery class that implements Billable without inheriting from PackageBase, and you can add new pricing strategies without changing your class hierarchy.

Alternative approaches (and when they beat inheritance or interfaces)

Inheritance and interfaces aren’t the only tools. In modern Java, I often reach for a few alternative patterns when the design starts to stretch.

Sealed classes for controlled inheritance

Sealed classes let you define a closed set of subclasses. This can be a strong signal that inheritance is intentional and controlled. If your domain has a fixed set of variants, sealed classes can make the inheritance line explicit and safer.

Records for simple data carriers

If you just need to pass data around with minimal behavior, records can reduce the temptation to create base classes with shared fields. You can use interfaces on top of records for capabilities without introducing inheritance at all.

Delegation with small services

Sometimes the right answer is to push behavior into dedicated services and keep entities simple. For example, instead of putting calculatePrice() inside a hierarchy, you might have a PricingService that takes a Billable or a PackageBase. This can reduce coupling and make testing easier.

Interface sprawl vs inheritance sprawl: how to avoid both

Two extremes are common:

  • Inheritance sprawl: a deep hierarchy of classes and subclasses.
  • Interface sprawl: dozens of tiny interfaces with no clear ownership or cohesion.

I avoid both by insisting on domain clarity. If a capability is important enough to be its own interface, it should have a clear definition and stable contract. If a shared identity is important enough to be a base class, it should have strong invariants and a meaningful role.

A simple litmus test I use:

  • If the interface name reads like a role the object plays, it’s likely valid (Trackable, Auditable).
  • If the base class name reads like a noun in the domain, it’s likely valid (Package, Account).
  • If you need a name like AbstractBaseThing, that’s a warning sign.

Testing strategies for inheritance and interfaces

Testing is where these choices become very concrete.

Inheritance testing

  • Test the base class by building a minimal subclass used only in tests.
  • Test each subclass’s unique behavior separately.
  • If the base class has template methods, test the template path end-to-end with multiple subclasses.

Interface testing

  • Define a contract test suite that any implementation must pass.
  • Run the same tests for each implementer.
  • This approach catches behavior drift and keeps contracts honest.

Here’s a tiny conceptual example of contract testing (pseudocode style):

interface ContractTests {

T createInstance();

default void priceShouldBeNonNegative() {

assertTrue(createInstance().price().compareTo(BigDecimal.ZERO) >= 0);

}

}

You can implement this test suite for each Billable class and validate behavior consistently.

Migration tips: untangling inheritance-heavy codebases

If you are inheriting from a tangled hierarchy, the idea of “switch to interfaces” can feel abstract. Here’s how I migrate in practice:

1) Identify which behaviors are truly shared and stable. Keep those in a base class, but cut everything else.

2) Introduce interfaces for cross-cutting roles and move references to those types.

3) Replace inherited behavior with composition where possible. Start with leaf classes to reduce risk.

4) Gradually flatten the hierarchy. Deep inheritance often contains “fake” layers that can be removed.

Migration is not a one-step refactor. It’s a sequence of safe moves that gradually reduce coupling.

Performance considerations in real systems

I mentioned earlier that performance differences are usually minor, but system-level performance does get affected by design decisions:

  • Deployment velocity: inheritance-heavy designs often require broader test coverage and coordinated releases.
  • Operational risk: a change in a base class can affect many services, which increases incident risk.
  • Onboarding cost: deep hierarchies are harder to learn, which slows new developers.

Interfaces and composition can reduce these costs by isolating changes. That’s a performance gain in the broader sense of the word.

A decision tree you can use today

When I’m stuck, I use this quick decision tree:

1) Is the relationship truly “is-a” and likely to stay that way for years?

  • Yes: consider a base class.
  • No: prefer interfaces or composition.

2) Do multiple unrelated types need the same capability?

  • Yes: use an interface.
  • No: don’t force it.

3) Do you need shared state with invariants?

  • Yes: base class or composition with a shared component.
  • No: interface only.

4) Do you have more than one axis of variation?

  • Yes: avoid deep inheritance; use interfaces + composition.

This sounds simple, but it saves a lot of time in architecture reviews.

Final takeaways

Inheritance and interfaces are not opposites; they are complementary tools. Inheritance is about shared identity and strict lifecycle. Interfaces are about capability and flexible polymorphism. The art is knowing where each boundary belongs. If you use inheritance for identity and interfaces for capabilities, and you apply composition when the model gets complex, you’ll build systems that grow smoothly instead of collapsing under their own cleverness.

The next time you face a design choice, don’t ask “which is more powerful?” Ask “which model will still be honest when we add three more features next quarter?” That question has saved me more time than any code shortcut ever did.

Scroll to Top