open atlas
↑ Back to track
Code patterns & craft CP · 09 · 01

Feature envy & inappropriate intimacy

Feature envy is behaviour living next to the wrong data; inappropriate intimacy is two classes reaching into each other's internals. Cure both by moving behaviour to the data it envies and restoring encapsulation — but don't relocate orchestration that legitimately spans objects.

CP Senior ◷ 20 min
Level
FoundationsJuniorMiddleSenior

You open a method and watch it reach across the table: order.lineItems, order.taxRate, order.shippingRegion, order.couponPercent. It touches its own object barely at all — every line is about somebody else’s data. The method lives in InvoicePrinter, but it is thinking about Order. It is in the wrong house.

That is feature envy, and its uglier sibling is inappropriate intimacy: two classes that have stopped knocking and just walk into each other’s bedrooms — reading private fields, mutating internal state, each assuming the other’s shape. Both smells are the same disease seen from different angles: behaviour has drifted away from the data it operates on, and the gap is paid for in coupling.

Goal

After this lesson you can recognise feature envy by the data-access ratio (a method that touches another object’s fields more than its own), recognise inappropriate intimacy by mutual private access between classes, apply Move Method and encapsulation to put behaviour back where its data lives, and — the senior part — tell genuine envy from legitimate orchestration so you don’t shove a use-case down inside a data object just to satisfy the rule.

1

Feature envy is a method more interested in another object’s data than its own. The diagnostic is mechanical: count the fields the method reads. If most of them belong to one other object, the method is envious of that object — it wants to be a method on it.

class InvoicePrinter {
  // envious: every field it touches belongs to `order`, none to `this`
  amountDue(order: Order): number {
    const subtotal = order.lineItems
      .reduce((s, li) => s + li.price * li.qty, 0);
    const tax = subtotal * order.taxRate;
    const shipping = order.shippingRegion === "intl" ? 25 : 5;
    return subtotal + tax + shipping - order.couponPercent * subtotal;
  }
}

amountDue reads lineItems, taxRate, shippingRegion, couponPercent — four pieces of Order’s state — and nothing of InvoicePrinter. The behaviour and the data have been separated, and the join between them is now a public surface Order has to expose. The fix is to move the method next to the data it envies.

2

The cure for feature envy is Move Method: relocate behaviour to the data it envies. Put amountDue on Order. Now it reads its own fields, Order can keep them private, and InvoicePrinter asks one question instead of four.

class Order {
  // behaviour now sits on the data it uses
  amountDue(): number {
    const tax = this.subtotal() * this.taxRate;
    const shipping = this.shippingRegion === "intl" ? 25 : 5;
    return this.subtotal() + tax + shipping - this.couponPercent * this.subtotal();
  }
  private subtotal(): number {
    return this.lineItems.reduce((s, li) => s + li.price * li.qty, 0);
  }
}

class InvoicePrinter {
  print(order: Order): string {
    return `Total: ${order.amountDue().toFixed(2)}`; // one call, zero envy
  }
}

Coupling dropped measurably: InvoicePrinter’s dependency on Order shrank from four fields to one method. taxRate, couponPercent, and shippingRegion can now be private — the rule that combines them lives with them. This is tell-don’t-ask: tell the order to compute its due amount; don’t ask it for its guts and do the arithmetic outside.

3

Inappropriate intimacy is feature envy made mutual: two classes reaching into each other’s internals. Feature envy is one-directional — a method wants another object’s data. Intimacy is bidirectional and structural — A reads/writes B’s private state and B reads/writes A’s. They become a single tangled unit pretending to be two classes.

class Account {
  balance = 0;
  history: Transaction[] = [];
}

class Transaction {
  apply(acct: Account) {
    acct.balance += this.amount;        // mutating Account's field
    acct.history.push(this);            // reaching into Account's internals
  }
}

class Account2 {
  reverse(tx: Transaction) {
    this.balance -= tx.amount;          // reading Transaction's field
    tx.reversed = true;                 // mutating Transaction's field
  }
}

Each class assumes the other’s exact shape and writes through it. Change Account’s balance representation (cents? a money type? an event log?) and Transaction.apply breaks; change Transaction’s fields and Account.reverse breaks. You cannot reason about, test, or evolve either one alone. The intimacy is the coupling.

4

Restore encapsulation: give each class a boundary, or extract the shared concept. Two moves dissolve intimacy. First, replace cross-object field access with intention-revealing methods, so neither class depends on the other’s internals — only on a small named interface:

class Account {
  private balance = 0;
  private history: Transaction[] = [];
  post(tx: Transaction): void {          // Account owns its own mutation
    this.balance += tx.signedAmount();
    this.history.push(tx);
  }
}

Transaction now exposes signedAmount() and Account owns every write to its own fields; neither reaches across. Second move, when the entanglement is because they share a concept neither owns (here: posting a balance change with its audit trail), extract that concept into its own type — a Posting or Ledger that both collaborate with cleanly. The senior heuristic: if moving a method just shifts the envy in the other direction, the missing element is usually a third object that doesn’t exist yet.

5

Failure mode: don’t relocate orchestration that legitimately spans several objects. The rule “move behaviour to the data it envies” assumes the behaviour belongs to one object’s data. But some methods touch many objects’ fields by design — they coordinate a workflow: a checkout that reads the Cart, charges via PaymentGateway, decrements Inventory, and writes an Order. That method looks envious of all four, yet it belongs to none of them. It is a use-case / service, and orchestration belongs at a higher level than any single data object.

// Legitimate orchestration — NOT feature envy. Do not jam this into Cart or Order.
class CheckoutService {
  async checkout(cart: Cart, payment: PaymentGateway, inventory: Inventory): Promise<Order> {
    const total = cart.total();               // each object does its own work...
    await payment.charge(total);              // ...the service only sequences them
    inventory.reserve(cart.items());
    return Order.from(cart);
  }
}

The tell: a service sequences calls to objects that each do their own work (cart.total(), payment.charge()), whereas envy does another object’s work for it (reading cart.taxRate and computing tax outside the cart). If you “fix” the orchestrator by pushing it inside Cart, you give Cart knowledge of payments and inventory it should never have — you traded a false smell for real coupling. Move behaviour to data; leave coordination above the data.

Worked example

Watch envy turn into a clean boundary. Start with a routing module that envies a Location:

// router.ts — envious: every field belongs to `from`/`to`, none to the router
class Router {
  distanceKm(from: Location, to: Location): number {
    const R = 6371;
    const dLat = (to.lat - from.lat) * Math.PI / 180;
    const dLon = (to.lng - from.lng) * Math.PI / 180;
    const a = Math.sin(dLat / 2) ** 2 +
      Math.cos(from.lat * Math.PI / 180) * Math.cos(to.lat * Math.PI / 180) *
      Math.sin(dLon / 2) ** 2;
    return R * 2 * Math.asin(Math.sqrt(a));
  }
}

distanceKm reads lat/lng off two Locations and nothing of Router. It is a behaviour about locations stranded in the router. Move it onto Location, where its data lives:

// location.ts — behaviour now sits with its data
class Location {
  constructor(private lat: number, private lng: number) {}

  distanceTo(other: Location): number {
    const R = 6371;
    const dLat = (other.latRad() - this.latRad());
    const dLon = (other.lngRad() - this.lngRad());
    const a = Math.sin(dLat / 2) ** 2 +
      Math.cos(this.latRad()) * Math.cos(other.latRad()) * Math.sin(dLon / 2) ** 2;
    return R * 2 * Math.asin(Math.sqrt(a));
  }
  private latRad() { return this.lat * Math.PI / 180; }
  private lngRad() { return this.lng * Math.PI / 180; }
}

// router.ts — now asks one question
class Router {
  routeLength(stops: Location[]): number {
    return stops.slice(1).reduce(
      (sum, stop, i) => sum + stops[i].distanceTo(stop), 0);
  }
}

Three wins, all from one move. lat/lng became privateRouter no longer knows Location stores degrees (it could switch to a geohash internally and Router wouldn’t change). Router’s job shrank to orchestration (routeLength sequences distanceTo calls) — which is exactly where the router should live, because summing a route legitimately spans many locations and belongs to no single one. The envy moved down to Location; the coordination stayed up in Router. That split is the whole lesson in one diff.

Why this works

Why does moving the method actually reduce coupling rather than just relocate it? Coupling is counted in the number of another object’s details you must know. The envious amountDue knew four Order fields; after the move, InvoicePrinter knows one Order method. Crucially, those four fields can now go private, which means Order is free to change how it stores tax and coupons without breaking anyone — the public surface shrank. Move Method doesn’t shuffle coupling around; it converts a wide data dependency into a narrow behavioural one and lets encapsulation do the rest. That conversion — many fields out, one verb in — is the mechanism behind “tell, don’t ask.”

Common mistake

The seductive mistake is treating “touches another object’s fields” as proof of envy and mechanically moving the method. Two false positives bite seniors. First, orchestration: a service that sequences cart, payment, and inventory reads many objects by design — push it into one of them and you give that object knowledge it must never hold. Second, the wrong home: a method may envy object B but, once moved, find it now envies C instead — the real fix is a missing third concept (Posting, Money, Route) that both should depend on, not a tug-of-war over which existing class hosts the method. Diagnose intent (does it do another object’s work, or merely sequence work the objects already do?) before you reach for Move Method.

Check yourself
Quiz

A CheckoutService method reads cart.total(), calls payment.charge(), and calls inventory.reserve(). A reviewer flags it as feature envy and says to move it into Cart. What's the most senior response?

Recap

Feature envy is a method more interested in another object’s data than its own — diagnose it by the field-access ratio. Inappropriate intimacy is the mutual case: two classes reaching into each other’s internals, fused into one unit. Both are behaviour that has drifted away from its data, and the gap is paid in coupling. The cures are Move Method (relocate behaviour to the data it envies, then make those fields private) and restoring encapsulation (replace cross-object field access with named methods, or extract the shared concept into a third type when moving just shifts the envy sideways). The senior discipline is the failure mode: a method that sequences several objects, each doing its own work, is orchestration — a use-case or service that belongs above any single data object. Move behaviour to data; never bury coordination inside a data object just to silence a field-counting rule.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.