Inheritance vs Composition: The Principle of Independent Variation Explains Why
"Favor composition over inheritance" is one of the most cited design guidelines in software engineering. But have you ever wondered why this works—and more importantly, when inheritance is actually appropriate?
The Principle of Independent Variation (PIV) provides a clear, principled answer by focusing on independent change drivers.
The Core Insight
PIV's guideline is elegantly simple:
When two concerns vary independently → prefer composition
When two concerns vary dependently → inheritance may be appropriate
Let me show you what this means in practice.
A Tale of Two Change Drivers
Consider a payment processing system. At first glance, you might design an inheritance hierarchy:
abstract class Payment {
// Infrastructure methods
protected void logTransaction(String details) { /* ... */ }
protected void detectFraud() { /* ... */ }
protected void sendNotification() { /* ... */ }
// Template method pattern
public final void process() {
validate();
detectFraud();
executePayment();
logTransaction("Payment processed");
sendNotification();
}
// Subclasses implement these
protected abstract void validate();
protected abstract void executePayment();
}
class CreditCardPayment extends Payment {
protected void validate() {
// Credit card validation
}
protected void executePayment() {
// Credit card processing
}
}
class PayPalPayment extends Payment {
protected void validate() {
// PayPal validation
}
protected void executePayment() {
// PayPal processing
}
}
This seems reasonable! We're reusing code, following OOP principles, right?
Identifying the Independent Change Drivers
PIV asks us to identify the change drivers—the reasons our code will need to change:
1. Payment Method Variety Driver
New payment methods emerge constantly:
- Credit cards
- PayPal
- Cryptocurrency
- Bank transfers
- Buy-now-pay-later services
Each has unique validation rules, processing logic, and external integrations. Crucially, adding cryptocurrency support should not require modifying credit card processing.
2. Common Payment Infrastructure Driver
Cross-cutting concerns that apply to all payments:
- Transaction logging
- Fraud detection
- Retry logic
- Audit trails
- Notification systems
These evolve for entirely different reasons: performance optimization, security updates, observability improvements.
Key observation: These drivers vary independently.
Payment methods change for business reasons (new providers, fee structures, regulations).
Infrastructure changes for technical reasons (performance, security, monitoring).
The Problem with Inheritance
Our inheritance approach couples these independent drivers:
❌ Tight Coupling
- Every payment method depends on the
Paymentbase class - Infrastructure changes force recompilation of ALL payment methods
- Even though their payment logic hasn't changed!
❌ Low Cohesion
Each payment subclass mixes multiple concerns:
- Payment method logic (validation, execution)
- Infrastructure usage (logging, fraud detection)
A developer working on credit card processing must understand logging infrastructure. An infrastructure specialist modifying fraud detection must understand all payment implementations.
❌ Rigidity
The template method enforces a fixed execution sequence. What if PayPal requires fraud detection after payment execution, not before? The base class structure must change, affecting all payment methods.
❌ Poor Testability
Testing CreditCardPayment requires instantiating the entire Payment hierarchy, including all infrastructure dependencies. You cannot isolate payment logic from infrastructure.
❌ The Fragile Base Class Problem
The base class becomes a dumping ground for shared functionality. As more payment methods are added, developers add more protected methods, creating an ever-growing, unfocused base class.
The Composition Solution
Now let's decouple those independent drivers:
// Payment method interface (domain abstraction)
interface PaymentMethod {
void validate();
PaymentResult executePayment();
}
// Concrete payment methods (vary independently)
class CreditCardPayment implements PaymentMethod {
public void validate() {
// Credit card validation
}
public PaymentResult executePayment() {
// Credit card processing
}
}
class PayPalPayment implements PaymentMethod {
public void validate() {
// PayPal validation
}
public PaymentResult executePayment() {
// PayPal processing
}
}
// Infrastructure services (vary independently)
interface TransactionLogger {
void log(String details);
}
interface FraudDetector {
boolean analyze(PaymentMethod method);
}
interface NotificationService {
void send(String message);
}
// Payment processor composes infrastructure and payment method
class PaymentProcessor {
private final TransactionLogger logger;
private final FraudDetector fraudDetector;
private final NotificationService notifier;
public PaymentProcessor(TransactionLogger logger,
FraudDetector fraudDetector,
NotificationService notifier) {
this.logger = logger;
this.fraudDetector = fraudDetector;
this.notifier = notifier;
}
public void process(PaymentMethod method) {
method.validate();
if (fraudDetector.analyze(method)) {
PaymentResult result = method.executePayment();
logger.log("Payment processed: " + result);
notifier.send("Payment confirmation");
}
}
}
The Benefits of Decoupling
✅ Independent Variation
-
Add payment methods without touching infrastructure: Just implement
PaymentMethod - Improve infrastructure without touching payment methods: Just update the service implementation
✅ High Cohesion
Each class has one responsibility:
-
CreditCardPaymenthandles credit card logic only -
TransactionLoggerhandles logging only -
PaymentProcessorhandles orchestration only
✅ Flexible Orchestration
Need fraud detection after execution for PayPal? Create a PayPalPaymentProcessor variant. Payment method implementations remain unchanged.
✅ Excellent Testability
- Test
CreditCardPaymentwith zero infrastructure dependencies - Mock
TransactionLoggerandFraudDetectorto test orchestration in isolation - Each concern is independently verifiable
✅ Explicit Dependencies
The PaymentProcessor constructor declares its infrastructure dependencies. No hidden inheritance magic. Reading the constructor reveals all concerns immediately.
So When IS Inheritance Appropriate?
PIV doesn't say "never use inheritance." It says: use inheritance when concerns vary dependently—when they always change together.
Appropriate use cases:
✓ True "is-a" Relationships
Geometric shapes with shared mathematical invariants:
abstract class Shape {
abstract double area();
abstract double perimeter();
}
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }
The shape concept and its implementations represent a unified concern that evolves together.
✓ Framework Extension Points
Stable, carefully designed template methods:
class HttpServlet {
// Stable framework lifecycle
public void service(HttpRequest req, HttpResponse res) {
if (req.getMethod().equals("GET")) {
doGet(req, res);
} else if (req.getMethod().equals("POST")) {
doPost(req, res);
}
}
protected void doGet(...) { }
protected void doPost(...) { }
}
The framework's request handling lifecycle is stable and well-designed. Subclasses extend specific, intentional extension points.
✓ Stable Domain Hierarchies
Well-established taxonomies that evolve slowly:
abstract class LegalEntity { /* ... */ }
class Person extends LegalEntity { /* ... */ }
class Company extends LegalEntity { /* ... */ }
The domain taxonomy represents fundamental knowledge that doesn't vary independently.
The Fundamental Pattern
PIV reveals a consistent pattern across design decisions:
When independent change drivers are separated → cohesion increases
When independent change drivers are coupled → cohesion decreases
This isn't just about inheritance vs composition. The same pattern explains:
- Why Dependency Inversion works (separating business rules from infrastructure)
- When immutability is beneficial (separating state evolution from concurrent access)
- How to choose between recursion and iteration (separating algorithm logic from execution control)
The Decision Framework
When facing the inheritance vs composition choice, ask yourself:
Identify the change drivers: What are the reasons this code will change?
-
Check independence: Do these drivers vary for different reasons?
- Business vs technical?
- Domain vs infrastructure?
- Different stakeholders?
-
If independent → Prefer composition
- Create interfaces for each driver
- Use dependency injection
- Keep concerns separated
-
If dependent → Inheritance may be fine
- They represent a unified concept
- They always evolve together
- The base class provides genuine shared behavior
The Takeaway
"Composition over inheritance" isn't a universal rule—it's a guideline that emerges from a deeper principle:
Separate elements that vary for independent reasons
PIV helps you identify when concerns are independent (use composition) and when they're genuinely unified (inheritance may be appropriate).
The next time you reach for inheritance, pause and ask: "Are these concerns varying independently?"
If yes, composition will give you better decoupling, higher cohesion, and easier maintenance.
If no, inheritance might be the right tool after all.
Learn More
This article is based on research from:
Loth, Y. (2025). The Principle of Independent Variation. Zenodo.
https://doi.org/10.5281/zenodo.17677316
The PIV paper explores this principle across multiple design decisions, providing a unifying framework for understanding why certain design patterns and principles work.
What design decisions have you reconsidered through the lens of independent change drivers? Share your experiences in the comments!
Top comments (0)