Skip to content
aipatterns.dev

Code smellCouplers

Inappropriate Intimacy

Two classes reach into each other's private fields and internals. Cut the back-and-forth by moving members or narrowing what each exposes.

Also known as
Inappropriate Closeness

Signs

  • Class A reads or writes fields of B that exist only for A to use, and B does the same to A.
  • Fields are made public, internal or package-private "so the other class can get at them".
  • Two classes hold references to each other and call back and forth during one operation.
  • A subclass depends on protected fields of its parent in ways the parent never documented.
  • You cannot change one class's internals without reading the other class first.

Why it happens

  • Two concepts started as one class and were split without deciding who owns what.
  • A quick fix needed one value, so a field became visible instead of a method being added.
  • A bidirectional association was added for convenience and both sides started using it.
  • Inheritance was used to share fields, so the child grew dependent on the parent's private layout.

Example

An Order and its Invoice edit each other's state directly.

before.ts
class Order {
  items: { sku: string; price: number; qty: number }[] = [];
  invoice?: Invoice;
  _discountApplied = false; // only Invoice uses this
}

class Invoice {
  total = 0;
  constructor(public order: Order) {
    order.invoice = this;
  }

  compute(): void {
    let sum = 0;
    for (const i of this.order.items) sum += i.price * i.qty;
    if (sum > 10000 && !this.order._discountApplied) {
      sum *= 0.95;
      this.order._discountApplied = true;
    }
    this.total = sum;
  }
}

Invoice walks Order's item list and flips a flag that only exists for it. Order keeps a back-reference it never uses.

after.ts
type LineItem = { sku: string; price: number; qty: number };

class Order {
  private readonly items: LineItem[] = [];

  add(item: LineItem): void {
    this.items.push(item);
  }

  subtotal(): number {
    return this.items.reduce((s, i) => s + i.price * i.qty, 0);
  }
}

class Invoice {
  readonly total: number;

  constructor(order: Order) {
    const subtotal = order.subtotal();
    this.total = subtotal > 10000 ? subtotal * 0.95 : subtotal;
  }
}

The discount flag disappears because the invoice computes the total once. The association now runs one way.

Treatment

Payoff

  • Each class can change its internals without breaking the other.
  • Invariants are enforced in one place, so state stays consistent.
  • The classes become testable on their own.

When to ignore

  • The two types are one concept split across files for size reasons, such as a builder and the object it builds, or a class and its private nested helper. Close access is intended there.
  • Languages offer scoped access for this case: Java package-private, C# internal, Kotlin internal, Rust pub(crate). Tight coupling inside one module is acceptable when the module boundary is the real API.
  • Performance-critical code where an accessor layer measurably costs too much. Measure first.

Agent checklist

  • List every member each class touches on the other and decide one owner per member.
  • Check for a cyclic import between the two modules before and after the change.
  • Reduce visibility of each field once no outside class reads it.
  • Keep public behavior identical and run the tests after each move.
  • Do not break up intentionally co-located types such as builders or nested helpers.

Treatments

See also

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