Skip to content
aipatterns.dev

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 instanceof to 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.

notifications.ts
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:

notifications.ts
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 instanceof dispatch 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 instanceof dispatch 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

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.