Loading content...
Understanding IS-A and HAS-A in theory is one thing; avoiding relationship mistakes in practice is another. Even experienced developers fall into predictable traps when designing class relationships—not from ignorance, but from subtle pressures: time constraints, superficial similarities, or the seductive promise of "code reuse."
This page catalogs the most common relationship mistakes, teaching you to recognize their symptoms, understand their consequences, and apply proven remediation strategies. By studying these anti-patterns, you'll develop the pattern recognition to avoid them in your own designs.
By the end of this page, you will recognize the most common IS-A vs HAS-A mistakes, understand why they occur and what damage they cause, and know how to refactor from incorrect relationships to correct ones.
The Pattern:
Two classes share some code, so a developer makes one inherit from the other—even though there's no genuine IS-A relationship.
Why It Happens:
Inheritance provides code reuse "for free"—write it once in the parent, get it automatically in the child. This is tempting, especially under time pressure. But it conflates two separate concerns: code reuse and type relationships.
The Damage:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// THE MISTAKE: Inheriting for code reuse// Stack doesn't actually have an IS-A relationship with ArrayListpublic class Stack<T> extends ArrayList<T> { public void push(T item) { add(item); // Reusing ArrayList.add() } public T pop() { if (isEmpty()) throw new EmptyStackException(); return remove(size() - 1); // Reusing ArrayList.remove() } public T peek() { if (isEmpty()) throw new EmptyStackException(); return get(size() - 1); // Reusing ArrayList.get() }} // PROBLEMS:Stack<String> stack = new Stack<>();stack.push("A");stack.push("B");stack.push("C"); // All these work but VIOLATE stack semantics:stack.add(0, "X"); // ArrayList method - violates LIFOstack.get(1); // Random access - not a stack operationstack.remove(0); // Remove from middle - violates LIFOstack.set(1, "Z"); // Modify middle - violates LIFO // THE FIX: Use composition (HAS-A)public class Stack<T> { private final List<T> elements = new ArrayList<>(); // HAS-A, not IS-A public void push(T item) { elements.add(item); } public T pop() { if (isEmpty()) throw new EmptyStackException(); return elements.remove(elements.size() - 1); } public T peek() { if (isEmpty()) throw new EmptyStackException(); return elements.get(elements.size() - 1); } public boolean isEmpty() { return elements.isEmpty(); } public int size() { return elements.size(); } // No get(), add(index), set(), remove(index) exposed! // Only stack operations are available.}Replace inheritance with composition. Create a private field for the "reused" class and delegate only the specific behaviors you need. This gives you reuse without polluting your public interface or creating false type relationships.
The Pattern:
A developer models roles or states that can change as inheritance hierarchies.
Why It Happens:
"A Manager IS A Employee" sounds correct in English, leading to class Manager extends Employee. But this ignores that a Person might transition between roles.
The Damage:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// THE MISTAKE: Roles as inheritancepublic class Employee { private String name; private BigDecimal salary;} public class Manager extends Employee { private List<Employee> directReports; public void addReport(Employee emp) { directReports.add(emp); }} public class Engineer extends Employee { private String specialization; private List<String> certifications;} // PROBLEMS:Engineer alice = new Engineer("Alice", "Backend");// Alice gets promoted to Engineering Manager...// Now what? You can't "change" alice to a Manager! // Terrible workaround:Manager aliceAsManager = new Manager();aliceAsManager.setName(alice.getName());aliceAsManager.setSalary(alice.getSalary());// Lost: certifications, specialization, alice's identity in other references! // THE FIX: Roles as compositionpublic class Employee { private final String id; private String name; private BigDecimal salary; private Set<Role> roles = new HashSet<>(); // HAS roles public void addRole(Role role) { roles.add(role); } public void removeRole(Role role) { roles.remove(role); } public <T extends Role> Optional<T> getRole(Class<T> roleType) { return roles.stream() .filter(roleType::isInstance) .map(roleType::cast) .findFirst(); } public boolean hasRole(Class<? extends Role> roleType) { return roles.stream().anyMatch(roleType::isInstance); }} public interface Role {} public class ManagerRole implements Role { private List<String> directReportIds;} public class EngineerRole implements Role { private String specialization; private List<String> certifications;} // Now transitions are trivial:Employee alice = new Employee("Alice");alice.addRole(new EngineerRole("Backend", List.of("AWS", "K8s"))); // Promotion to Manager:alice.addRole(new ManagerRole(List.of("bob-id", "carol-id")));// Alice is now both an Engineer and a Manager! // Later, if Alice moves to pure management:alice.getRole(EngineerRole.class).ifPresent(alice::removeRole);The Pattern:
A subclass is created that looks like an IS-A relationship syntactically, but violates the behavioral contract of the parent class.
Why It Happens:
Developers focus on syntax (method signatures) rather than semantics (behavioral contracts). The type system happily compiles code that is semantically broken.
The Damage:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// THE MISTAKE: Subclass that violates parent's contractpublic class Bird { public void fly() { System.out.println("Flying through the air"); } public void eat() { System.out.println("Eating food"); }} public class Penguin extends Bird { @Override public void fly() { throw new UnsupportedOperationException("Penguins can't fly!"); }} // PROBLEM: Client code that depends on Bird contract breakspublic class BirdSanctuary { public void releaseBirds(List<Bird> birds) { for (Bird bird : birds) { bird.fly(); // BOOM! UnsupportedOperationException for penguins } }} // THE FIX: Rethink the hierarchy// Option 1: Separate hierarchiespublic interface Animal { void eat();} public interface FlyingAnimal extends Animal { void fly();} public class Sparrow implements FlyingAnimal { public void fly() { /* ... */ } public void eat() { /* ... */ }} public class Penguin implements Animal { // Does NOT implement FlyingAnimal public void eat() { /* ... */ } public void swim() { /* ... */ } // Penguin-specific} // Option 2: Capability-based compositionpublic interface MovementCapability {} public class FlyingCapability implements MovementCapability { public void fly() { /* ... */ }} public class SwimmingCapability implements MovementCapability { public void swim() { /* ... */ }} public class Bird { private final List<MovementCapability> capabilities; public Bird(MovementCapability... caps) { this.capabilities = Arrays.asList(caps); } public <T extends MovementCapability> Optional<T> getCapability(Class<T> type) { return capabilities.stream() .filter(type::isInstance) .map(type::cast) .findFirst(); }} // Now:Bird sparrow = new Bird(new FlyingCapability(), new WalkingCapability());Bird penguin = new Bird(new SwimmingCapability(), new WalkingCapability());// No false promises!When you see if (bird instanceof Penguin) { /* skip flying */ } in client code, that's a red flag. Clients shouldn't need to know about specific subtypes—that defeats the purpose of polymorphism. This pattern indicates an LSP violation in the hierarchy.
The Pattern:
An inheritance hierarchy grows to 4, 5, 6+ levels deep, with each level adding small increments of behavior.
Why It Happens:
Developers keep specializing: Animal → Mammal → Canine → Dog → Retriever → GoldenRetriever → EnglishGoldenRetriever. Each step seems reasonable in isolation.
The Damage:
| Depth | Assessment | Recommendation |
|---|---|---|
| 1-2 levels | Healthy | Continue as appropriate |
| 3 levels | Caution | Question if all levels are necessary |
| 4+ levels | Red flag | Refactor—use composition or traits |
| 5+ levels | Anti-pattern | Almost certainly wrong; requires immediate refactoring |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// THE MISTAKE: 5-level hierarchypublic class Component { }public class Container extends Component { }public class Window extends Container { }public class Dialog extends Window { }public class FormDialog extends Dialog { }public class ValidationFormDialog extends FormDialog { } // To understand ValidationFormDialog, you must trace through 5 parents! // THE FIX: Flatten with compositionpublic class Dialog { private final WindowBehavior windowBehavior; // HAS-A private final ContainerBehavior containerBehavior; // HAS-A private final DialogStyle style; private final FormHandler formHandler; // HAS-A for form behavior private final Validator validator; // HAS-A for validation behavior public Dialog(DialogStyle style, FormHandler formHandler, Validator validator) { this.windowBehavior = new WindowBehavior(); this.containerBehavior = new ContainerBehavior(); this.style = style; this.formHandler = formHandler; this.validator = validator; } // Expose only what's needed public void show() { windowBehavior.makeVisible(); } public void submit() { if (validator.validate(formHandler.getData())) { formHandler.submit(); close(); } } // Factory methods for common configurations public static Dialog simpleDialog() { return new Dialog(DialogStyle.SIMPLE, null, Validator.NONE); } public static Dialog formDialog(FormHandler handler) { return new Dialog(DialogStyle.FORM, handler, Validator.NONE); } public static Dialog validatedFormDialog(FormHandler handler, Validator validator) { return new Dialog(DialogStyle.FORM, handler, validator); }} // Now: 1-level class with composed behaviors// Easy to understand, easy to test, easy to extendIf your hierarchy exceeds 3 levels, pause and ask: 'Can I express these variations through composition instead?' Often, what looks like a classification hierarchy is actually a combination of orthogonal features that compose better than they inherit.
The Pattern:
An abstract base class accumulates shared utility code, becoming a grab-bag of unrelated functionality that all subclasses inherit.
Why It Happens:
"All our services need logging, metrics, and database access. Let's put it in a BaseService and extend from there." Convenient—but it violates the Single Responsibility Principle and creates hidden coupling.
The Damage:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// THE MISTAKE: Abstract class as grab-bagpublic abstract class BaseService { protected final Logger logger = LoggerFactory.getLogger(getClass()); protected final MetricsRegistry metrics; protected final DataSource dataSource; protected final Cache cache; protected final HttpClient httpClient; protected BaseService(MetricsRegistry metrics, DataSource ds, Cache cache, HttpClient client) { this.metrics = metrics; this.dataSource = ds; this.cache = cache; this.httpClient = client; } // "Helpful" utility methods protected void logInfo(String msg) { logger.info(msg); } protected void recordMetric(String name, double value) { /* ... */ } protected Connection getConnection() throws SQLException { /* ... */ } protected <T> T cacheGet(String key, Class<T> type) { /* ... */ } protected String httpGet(String url) { /* ... */ }} public class UserService extends BaseService { // Only needs dataSource, but forced to accept cache, httpClient too!} public class NotificationService extends BaseService { // Only needs httpClient, but forced to accept dataSource, cache too!} // THE FIX: Composition with explicit dependenciespublic class UserService { private final Logger logger = LoggerFactory.getLogger(UserService.class); private final UserRepository userRepository; // Only what's needed private final MetricsRegistry metrics; // Only what's needed public UserService(UserRepository userRepository, MetricsRegistry metrics) { this.userRepository = userRepository; this.metrics = metrics; }} public class NotificationService { private final Logger logger = LoggerFactory.getLogger(NotificationService.class); private final NotificationProvider provider; // Only what's needed public NotificationService(NotificationProvider provider) { this.provider = provider; }} // Each service declares exactly what it needs// No hidden dependencies, no forced baggage// Easy to test with minimal mockingHow do you know if you've made a relationship mistake? Watch for these symptoms in existing code:
| Symptom | Likely Mistake | Verification | Fix Direction |
|---|---|---|---|
instanceof checks in client code | LSP violation or wrong IS-A | Check if substitution truly works | Redesign hierarchy or use composition |
Methods that throw UnsupportedOperationException | LSP violation | Subclass can't fulfill parent contract | Split interface or use composition |
| Subclass that ignores most parent methods | IS-A for code reuse | Check if IS-A sentence makes sense | Convert to HAS-A with delegation |
| Difficulty writing tests without mocking everything | Abstract grab-bag or deep hierarchy | Check dependency count | Flatten hierarchy, inject dependencies |
| 'Type' or 'Kind' enums with switch statements | Missed polymorphism or wrong hierarchy | Check if types should be classes | Extract to separate classes or composition |
| Can't add feature without modifying base class | Too much in base, not enough composition | Check if feature is shared by all subclasses | Move to composition, use decorator |
Code Review Checklist for Relationships:
When reviewing new inheritance:
If any answer is "no," require refactoring before merge.
When you've identified a relationship mistake, here are proven refactoring strategies:
Strategy 1: Replace Inheritance with Delegation
For the IS-A-for-code-reuse mistake:
Strategy 2: Extract Role Objects
For the inheritance-of-changeable-roles mistake:
Strategy 3: Flatten Hierarchy
For excessively deep hierarchies:
Changing inheritance relationships is high-risk refactoring. Ensure comprehensive test coverage before starting. Make changes incrementally—one step at a time—and verify tests pass at each step. Consider using the Strangler pattern for large hierarchies.
Prior to Refactoring:
We've catalogued the most common IS-A vs HAS-A mistakes, their causes, symptoms, and remedies. Let's consolidate:
instanceof, UnsupportedOperationException, testing difficulty, type enumsModule Complete:
You now have a complete understanding of IS-A vs HAS-A relationships:
With this knowledge, you can design class relationships that are semantically correct, flexible, and maintainable—avoiding the traps that plague many codebases.
You've mastered the critical distinction between IS-A and HAS-A relationships. You can now recognize when each applies, test your designs systematically, and avoid the common mistakes that lead to fragile, rigid hierarchies. Next, you'll learn the famous 'Favor Composition Over Inheritance' principle and when it does—and doesn't—apply.