Code smellChange Preventers
Parallel Inheritance Hierarchies
Every new subclass in one hierarchy forces a matching subclass in another, so two trees must always grow in lockstep.
Signs
- Two class trees share name prefixes:
EmailNotification/EmailNotificationRenderer,SmsNotification/SmsNotificationRenderer. - Adding a subclass to one tree fails at runtime or compile time until you add its twin to the other.
- A factory or map pairs each class in tree A with exactly one class in tree B.
- Code downcasts or checks
instanceofto find the matching partner.
Why it happens
It starts as sensible separation: data in one class, the code that acts on it in another. With two or three variants it looks clean. Each new variant then adds a pair, and the pairing rule lives only in developers' heads or in a registry. It is a special case of Shotgun Surgery, where the scattered edit is a whole new class.
Example
A notification system has one tree for messages and another for senders.
abstract class Notification {
constructor(readonly userId: string, readonly text: string) {}
}
class EmailNotification extends Notification {
constructor(userId: string, text: string, readonly subject: string) {
super(userId, text);
}
}
class SmsNotification extends Notification {}
abstract class Sender<N extends Notification> {
abstract send(n: N): Promise<void>;
}
class EmailSender extends Sender<EmailNotification> {
async send(n: EmailNotification) { /* SMTP with n.subject */ }
}
class SmsSender extends Sender<SmsNotification> {
async send(n: SmsNotification) { /* SMS gateway, 160 chars */ }
}
function senderFor(n: Notification): Sender<any> {
if (n instanceof EmailNotification) return new EmailSender();
if (n instanceof SmsNotification) return new SmsSender();
throw new Error("no sender"); // adding PushNotification breaks here
}Collapse the pairing by moving the sending behavior onto the notification and passing the transport in:
interface Transports {
smtp(to: string, subject: string, body: string): Promise<void>;
sms(to: string, body: string): Promise<void>;
}
abstract class Notification {
constructor(readonly userId: string, readonly text: string) {}
abstract send(t: Transports): Promise<void>;
}
class EmailNotification extends Notification {
constructor(userId: string, text: string, readonly subject: string) {
super(userId, text);
}
send(t: Transports) { return t.smtp(this.userId, this.subject, this.text); }
}
class SmsNotification extends Notification {
send(t: Transports) { return t.sms(this.userId, this.text.slice(0, 160)); }
}A push notification is now one class, not two plus a registry edit.
Treatment
- If the second hierarchy's classes hold behavior for exactly one class in the first, use Move Method to put that behavior on its partner, then delete the second tree.
- If the partner classes also carry data, use Move Field before moving methods.
- If the two trees must stay separate (for example, domain objects that must not depend on infrastructure), keep one hierarchy and make the other a set of instances or functions keyed by type, so a new variant needs one registration, not a new subclass.
- If both trees vary independently (N shapes times M renderers), the pairing is real; use Bridge so each tree grows on its own.
- If you need many operations over a stable set of types, Visitor makes the pairing explicit and type-checked.
Payoff
- A new variant is one class in one place.
- No registry, no
instanceofdispatch and no runtime "missing partner" errors. - The pairing rule is enforced by the type system rather than by memory.
When to ignore
- The separation is a deliberate layering boundary, such as domain types that must not import email or SMS libraries. Accept the pairs, and add a test that fails when a partner is missing.
- The trees vary independently and the cross product is real. That is a Bridge, not a smell.
- There are two or three stable variants and no new ones in sight.
Agent checklist
- You confirmed the trees are paired one-to-one, not independently varying.
- Moving behavior across does not make a domain class depend on infrastructure it should not know.
- Every
instanceofdispatch or partner registry tied to the pairing was removed or reduced. - A test covers each variant's behavior after the merge.
- If you kept both trees on purpose, a test fails when a new variant lacks its partner.
Treatments
See also
- Shotgun SurgeryOne logical change forces small edits in many classes, so one missed edit ships an inconsistent system.
- Switch StatementsThe same switch or if-chain over a type code is repeated across the codebase, so adding one case means editing every copy.
- BridgeSplit a class that varies along two dimensions into two hierarchies joined by composition, so each side grows without multiplying subclasses.
- VisitorAdd new operations over a fixed set of node types without editing those types, by routing each node to a type-specific method on a visitor object.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.