Loading content...
We've established precise definitions for IS-A (inheritance) and HAS-A (composition). But in the heat of design, how do you quickly and accurately determine which relationship applies? Intuition is unreliable—natural language is ambiguous, and initial impressions often mislead.
What we need is a systematic framework: a set of tests and heuristics that reliably distinguish IS-A from HAS-A relationships. This page provides exactly that—a rigorous decision process you can apply to any design scenario.
The goal isn't just to make correct decisions; it's to make them confidently and quickly, with clear reasoning you can articulate to teammates during design discussions.
By the end of this page, you will have a multi-step decision framework for choosing between IS-A and HAS-A, understand multiple verification heuristics, and be able to apply these tests to ambiguous design scenarios with confidence.
Start with these three fundamental tests. Each addresses a different aspect of the relationship, and together they form a reliable filter.
Test 1: The Sentence Test
Formulate two sentences:
Which sounds correct in the problem domain?
Important: Use domain terminology, not programming concepts. Ask this question as if you were explaining to a domain expert who doesn't program.
Test 2: The Substitution Test
If considering inheritance, ask: "Can I use ClassA wherever ClassB is expected, and will everything still work correctly?"
This test catches cases where the sentence test might mislead (like Square/Rectangle).
Test 3: The 100% Rule
For IS-A to be valid, the subclass must satisfy 100% of the superclass contract:
If any inherited capability doesn't apply, IS-A is wrong.
| Proposed Relationship | Sentence Test | Substitution Test | 100% Rule | Result |
|---|---|---|---|---|
| Dog extends Animal | ✅ Dog IS AN Animal | ✅ Dog works as Animal | ✅ All Animal behaviors apply | ✅ IS-A valid |
| Car contains Engine | ✅ Car HAS AN Engine | N/A (not inheritance) | N/A | ✅ HAS-A valid |
| Stack extends ArrayList | ⚠️ Debatable | ❌ Stack has different semantics | ❌ Many ArrayList methods wrong for Stack | ❌ Use HAS-A |
| Square extends Rectangle | ✅ Square IS A Rectangle (math) | ❌ setWidth breaks Square | ❌ Independent width/height is invalid for Square | ❌ Use HAS-A or rethink |
| Employee extends Person | ✅ Employee IS A Person | ✅ Employee works as Person | ✅ All Person behaviors apply | ✅ IS-A valid |
Beware relying solely on the sentence test. Natural language is imprecise—'Square IS A Rectangle' sounds correct mathematically, but fails the substitution and 100% tests. Always apply all three tests, especially when designing critical hierarchies.
Beyond the primary tests, these additional heuristics help verify your decision and catch edge cases:
Heuristic 1: The Lifetime Test
Does the relationship hold for the entire lifetime of the object?
If an object's classification can change over time, IS-A is problematic. Roles that can change are better modeled with HAS-A.
Heuristic 2: The Reusability Test
Is the potential component useful in other contexts?
Reusable components suggest HAS-A; domain-specific classifications suggest IS-A.
Heuristic 3: The Replaceability Test
Might you want to swap the component?
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// PROBLEMATIC: Role that changes over time modeled as IS-Apublic class Person { } public class Student extends Person { private String university;} public class Employee extends Person { private String company;} // What happens when a student graduates and becomes an employee?// You can't "change" the object from Student to Employee!// The IS-A relationship was wrong. // BETTER: Roles as HAS-A relationshipspublic class Person { private String name; private List<Role> roles; // HAS roles public void addRole(Role role) { roles.add(role); } public void removeRole(Role role) { roles.remove(role); } public boolean hasRole(Class<? extends Role> roleType) { return roles.stream().anyMatch(roleType::isInstance); }} public interface Role { } public class StudentRole implements Role { private String university; private LocalDate enrollmentDate;} public class EmployeeRole implements Role { private String company; private LocalDate startDate;} // Now a person can be a student, graduate, and become an employeePerson alice = new Person("Alice");alice.addRole(new StudentRole("MIT", LocalDate.of(2020, 9, 1))); // After graduationalice.removeRole(...); // Graduatealice.addRole(new EmployeeRole("Google", LocalDate.of(2024, 6, 15))); // Or even be both simultaneously (part-time student, full-time employee)alice.addRole(new StudentRole("Stanford", ...)); // Evening MBA programHeuristic 4: The Method Relevance Test
Look at the methods of the potential parent class. For each method, ask: "Does this make sense for my child class?"
ArrayList.get(int index) make sense for Stack? ❌ No (stacks don't have random access)Animal.eat() make sense for Dog? ✓ YesIf any base class method is inappropriate, IS-A is wrong.
Heuristic 5: The Expansion Test
Imagine the system growing. If we add new subclasses or new components:
If growth strains the IS-A hierarchy, consider HAS-A for flexibility.
Here's a step-by-step decision process combining all tests:
Step 1: Apply the Sentence Test
Ask: "A [Child] IS A [Parent]" — does this make semantic sense in the domain?
Step 2: Apply the Substitution Test
Ask: Can a Child instance be used wherever a Parent is expected, with identical correctness guarantees?
Step 3: Apply the 100% Rule
Ask: Do ALL of the Parent's methods, invariants, and contracts apply sensibly to the Child?
Step 4: Apply the Lifetime Test
Ask: Does this classification hold permanently for the object's entire lifetime?
Step 5: Check for Practical Concerns
Ask: Is the parent class designed for inheritance? Are there flexibility requirements? Is single inheritance a limitation?
The Default Bias:
Note that the flowchart has a default bias toward HAS-A. This is intentional. Modern design wisdom ("favor composition over inheritance") recognizes that:
IS-A should be reserved for cases where it's clearly appropriate and provides meaningful value.
Some design scenarios genuinely are ambiguous—reasonable engineers might disagree. Here's how to handle the trickiest cases:
Case 1: The Classic Square-Rectangle Problem
Mathematically, a square IS a rectangle. But in code:
Resolution: The mathematical IS-A doesn't imply behavioral IS-A. If the subclass can't honor the parent's behavioral contract, use composition or redesign.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// THE PROBLEMpublic class Rectangle { protected int width; protected int height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; }} public class Square extends Rectangle { @Override public void setWidth(int width) { this.width = width; this.height = width; // Must maintain square invariant } @Override public void setHeight(int height) { this.width = height; // Must maintain square invariant this.height = height; }} // The LSP violation:void clientCode(Rectangle rect) { rect.setWidth(5); rect.setHeight(4); // Client expects area = 20 (5 * 4) assert rect.getArea() == 20; // FAILS for Square!} // SOLUTION 1: Don't use inheritance at allpublic interface Shape { int getArea(); }public class Rectangle implements Shape { /* ... */ }public class Square implements Shape { /* ... */ } // SOLUTION 2: Immutable shapes (no setters)public class Rectangle { private final int width; private final int height; public Rectangle(int width, int height) { this.width = width; this.height = height; } // No setters! Create new instances for changes. public Rectangle withWidth(int newWidth) { return new Rectangle(newWidth, height); } public int getArea() { return width * height; }} public class Square extends Rectangle { public Square(int side) { super(side, side); // Now invariant can't be violated } @Override public Rectangle withWidth(int newWidth) { // Returns Rectangle, not Square—that's correct behavior! return new Rectangle(newWidth, this.getHeight()); }}Case 2: Role vs Identity
Is a Manager an Employee, or does an Employee have a Manager role?
Resolution: Ask "Can this change?" If an employee can be promoted to manager, or a manager demoted, it's a role (HAS-A). If a Manager is fundamentally a different type of entity that just happens to share some Employee characteristics, IS-A might apply.
| Characteristic | Role (HAS-A) | Identity (IS-A) |
|---|---|---|
| Can it change? | Yes—roles can be added/removed | No—identity is permanent |
| Multiple simultaneously? | Yes—one person, many roles | No—one entity, one type |
| Shared attributes? | Few—role-specific data | Many—shared core identity |
| Examples | StudentRole, ManagerRole | Dog IS Animal, Car IS Vehicle |
Case 3: Implementation Inheritance (Reuse-Driven)
You want to reuse a lot of code from an existing class. IS-A would give you that code for free.
Resolution: This is the most dangerous trap. Never use IS-A purely for code reuse. If there's no genuine type relationship, use composition and delegate. The slight extra code for delegation is worth the flexibility and correctness.
Case 4: Framework Extension
A framework requires you to extend a base class (e.g., HttpServlet, Activity).
Resolution: When a framework mandates inheritance, follow the framework's design. This is a valid IS-A relationship because you are implementing a framework extension point—your class genuinely IS A servlet or activity in that framework's type system.
If you've applied all tests and are still uncertain, default to HAS-A. It's easier to refactor from composition to inheritance (discovering a genuine IS-A) than vice versa. Composition is the safer bet when genuinely uncertain.
Design decisions are rarely made in isolation. Here's how to facilitate productive discussions about IS-A vs HAS-A:
Articulating Your Reasoning:
When proposing a relationship type, explain your reasoning using the tests:
"I'm proposing that Order HAS-A List of LineItems rather than LineItem being a subclass because: (1) 'LineItem IS AN Order' fails the sentence test, (2) LineItems don't substitute for Orders, and (3) LineItems have a different lifecycle—they're created by the Order."
This is concrete and verifiable—teammates can agree or challenge specific points.
Challenging Proposed Relationships:
When reviewing others' designs, use the tests as questions:
"You've made Stack extend ArrayList. Let's apply the 100% rule—does
ArrayList.get(5)make sense for a Stack? No, stacks only access the top element. I think Stack HAS-A ArrayList would be more accurate."
Documenting Decisions:
For non-obvious relationship choices, document the reasoning:
/**
* Order uses composition for LineItems because:
* - LineItems don't exist independently (100% rule: they're not Orders)
* - The relationship is containment, not classification
* - LineItems are created and owned by the Order (lifecycle bound)
*
* We considered making LineItem a general Item subclass but rejected it
* because LineItems have Order-specific behavior (quantity, line total).
*/
class Order {
private List<LineItem> items;
}
Apply the decision framework to these scenarios:
Scenario 1: FileLogger and DatabaseLogger
Both log messages but to different destinations.
Applying the tests:
Verdict: IS-A is appropriate. Both are specialized types of Loggers.
Scenario 2: User and Admin
Admins have all user capabilities plus additional permissions.
Applying the tests:
Verdict: The lifetime test fails. Use HAS-A with roles. An Admin is a User with admin privileges, not a different type of entity.
Scenario 3: EncryptedFile and CompressedFile
Both are special kinds of files with additional processing.
Applying the tests:
Verdict: Single inheritance breaks down. What if a file is both encrypted AND compressed? You can't extend both EncryptedFile and CompressedFile. Use HAS-A with decorators/strategies.
We've established a rigorous, multi-step framework for determining whether IS-A or HAS-A applies. Let's consolidate:
What's Next:
Now that we can choose the right relationship type, the final page examines what happens when we get it wrong—the common relationship mistakes that plague codebases, their symptoms, and how to fix them.
You now have a complete toolkit for testing whether IS-A or HAS-A applies to any design scenario. Apply these tests systematically, default to HAS-A when uncertain, and document your reasoning. Next, we'll examine common relationship mistakes and how to avoid them.