A method belongs to InvoiceService, but most of its code asks an Invoice for fields, combines those fields according to invoice rules, and barely uses the service’s own state. Every time the invoice model changes, the service changes with it.

This is a common design smell called feature envy: behavior lives in one place but depends heavily on the data or rules owned by another place. The name is less important than the maintenance problem. Knowledge that belongs together is split across a boundary, so one conceptual change requires coordinated edits.

This article shows how to recognize feature envy, decide whether moving behavior will actually improve the design, perform the refactoring in small steps, and avoid the opposite mistake of putting every operation on the object that holds the data.

Start by following the data a method uses

Consider a simplified invoice calculation:

function amountDue(invoice):
  subtotal = invoice.lineItemsTotal
  discount = subtotal * invoice.discountRate
  taxable = subtotal - discount
  tax = taxable * invoice.taxRate
  return taxable + tax

Suppose this function lives in InvoiceService.

The service is not coordinating storage, external calls, or several domain objects. The function simply reads invoice data and applies rules that describe an invoice’s amount due.

That is the useful signal. Ask:

Which object’s knowledge does this behavior mostly use?

If the answer is consistently “some other object,” the behavior may be in the wrong place.

A possible refactoring is:

invoice.amountDue()

with the calculation implemented beside the invoice data and invariants.

The goal is not shorter syntax. The goal is to give the calculation a home where the facts it depends on can change together.

Feature envy is about knowledge, not call counts

Counting method calls can reveal a smell, but it is not enough to diagnose one.

This code calls invoice several times:

renderInvoice(invoice):
  print(invoice.number)
  print(invoice.customerName)
  print(invoice.total)

A renderer legitimately needs invoice information to present it. Moving rendering into Invoice could mix presentation concerns with domain behavior.

Now compare:

isOverdue(invoice, today):
  return invoice.balance > 0 and today > invoice.dueDate

This function uses fewer fields, but the rule may belong much more strongly to the invoice concept. Whether an invoice is overdue depends on invoice state and the meaning of its due date and balance.

So do not ask only, “which object receives the most calls?” Ask:

  • Whose rule is this?
  • Which data must the code understand to implement the rule?
  • Which changes are likely to affect both the data and this behavior?

Feature envy is a problem when behavior must understand another component’s internal concepts well enough that the two effectively change together.

Move a complete decision, not just a few lines

A weak refactoring often moves arithmetic while leaving the real rule outside.

Suppose a service contains:

if invoice.status == "open" and invoice.balance > 0:
  if today > invoice.dueDate:
    fee = invoice.balance * 0.02

Moving only the multiplication gives:

if invoice.status == "open" and invoice.balance > 0:
  if today > invoice.dueDate:
    fee = invoice.calculateFee()

The caller still knows all the conditions under which the fee applies. The rule remains distributed.

A more coherent boundary is:

fee = invoice.lateFee(today)

The invoice can own the complete decision:

lateFee(today):
  if status != Open:
    return 0

  if balance <= 0:
    return 0

  if today <= dueDate:
    return 0

  return balance * lateFeeRate

Now a change such as “closed invoices never incur late fees” or “the grace period is three days” has one obvious home if those are genuinely invoice rules.

The important refactoring unit is the decision, not the smallest extractable expression.

Pass external facts instead of pulling infrastructure inward

Moving behavior toward data does not mean the domain object should start querying the world.

For example, avoid turning this:

isOverdue(invoice, clock.now())

into this:

invoice.isOverdue(clock)

if isOverdue only needs the current date. Passing a clock service makes the invoice depend on an infrastructure-shaped collaborator just to obtain one fact.

Prefer:

invoice.isOverdue(today)

The caller obtains the external fact; the invoice applies its rule.

The same principle works for exchange rates, configuration, or externally retrieved eligibility information. Pass the relevant value when the object needs a fact, unless interacting with the collaborator is itself part of the object’s responsibility.

This keeps the moved behavior focused on domain knowledge rather than dragging I/O and lifecycle concerns into the data owner.

Preserve boundaries around orchestration

Some behavior belongs outside an entity even when it uses entity data.

Imagine checkout code that must:

  1. load an order;
  2. ask the order whether it can be submitted;
  3. reserve inventory through another system;
  4. persist the submitted state;
  5. publish a confirmation event.

The order can own rules about whether its state permits submission. It should not necessarily own the repository, inventory client, and event publisher.

A useful split is:

order.canSubmit()        // domain decision
inventory.reserve(...)  // external interaction
order.markSubmitted()   // domain state transition
orders.save(order)       // persistence
publisher.publish(...)   // external interaction

An application service or workflow coordinator can orchestrate these steps.

This distinction prevents a common overcorrection. Fixing feature envy does not require turning one object into the center of every operation that mentions it. Move knowledge and decisions toward their natural owner; keep cross-boundary coordination where the participating dependencies can be managed explicitly.

Use invariants as a clue to ownership

An invariant is a condition that valid state must preserve. Behavior that protects an invariant is a strong candidate to live with the state it protects.

Suppose an account must never have a negative reserved amount:

function release(account, amount):
  if amount <= 0:
    reject

  if amount > account.reserved:
    reject

  account.reserved = account.reserved - amount

If many callers manipulate reserved directly, each caller must remember the same constraints. The account’s validity depends on outside code behaving correctly.

Moving the transition behind the account boundary:

account.release(amount)

allows the object to enforce the rule wherever the operation is used.

This is stronger than moving a convenience calculation. The behavior controls whether the object’s state remains valid, so keeping the rule with the state reduces the number of places that must know the invariant.

If the language supports stronger encapsulation, restricting direct writes to reserved can reinforce the boundary. The exact mechanism varies, but the design intent is consistent: callers request a valid operation instead of reconstructing the state transition themselves.

Refactor in small, behavior-preserving steps

Moving behavior can change public interfaces and dependencies, so treat it as a refactoring rather than a rewrite.

For the late-fee example, a safe sequence is:

  1. Identify the existing behavior and cover important cases with tests if reliable coverage is missing.
  2. Add invoice.lateFee(today) with the same rules as the current code.
  3. Change one caller to use the new operation.
  4. Run the relevant tests and compare behavior at boundary cases.
  5. Move remaining callers.
  6. Remove the old duplicated rule only after no caller depends on it.

Boundary cases deserve particular attention. If the old rule uses today > dueDate, changing it accidentally to today >= dueDate changes behavior exactly on the due date. Refactoring should preserve such semantics unless the requirement is intentionally changing too.

When the original behavior is poorly understood, characterize what it currently does before moving it. Separating “where this code lives” from “what this code should do” makes failures easier to interpret.

Watch for the opposite smell: an object that knows too much

Moving every operation onto a domain object can create an oversized class with unrelated reasons to change.

An Invoice does not automatically need methods for:

saveToDatabase()
renderAsPdf()
sendByEmail()
exportForAccountingVendor()

Those operations involve persistence, presentation, delivery, and external integration. They may use invoice data, but they do not necessarily express invoice rules.

A useful ownership test is:

Would this behavior still conceptually belong here if the infrastructure or presentation mechanism changed?

invoice.amountDue() probably would. invoice.renderAsPdf() may not if PDF is just one presentation format. invoice.saveToDatabase() ties the object to a persistence mechanism that can change independently of invoice behavior.

Cohesion improves when behavior that changes for the same reason stays together. It degrades when an object becomes a collection point for every operation that happens to touch its data.

Do not confuse data transfer objects with domain objects

Some objects are intentionally data carriers.

An API request, serialized event, database row mapping, or report projection may exist primarily to move information across a boundary. Adding business behavior to such structures can blur the distinction between an external representation and the application’s own model.

For example:

CreateOrderRequest {
  customerId
  items
  couponCode
}

may be a transport structure. The request should not necessarily decide pricing, inventory policy, or order validity merely because it contains relevant fields.

The application can parse the request into domain values and let domain components own those decisions.

Before moving behavior, identify what the target object represents. Feature envy is most useful when there is a meaningful owner for the knowledge, not when behavior is simply pushed onto the nearest data structure.

Recognize legitimate multi-object decisions

Some rules genuinely depend on several concepts.

Suppose a transfer rule compares a source account, destination account, transfer amount, customer limits, and a risk decision. Forcing the entire rule onto either account may make that object depend on concepts it should not own.

A dedicated policy can be clearer:

transferPolicy.evaluate(source, destination, amount, limits, risk)

The policy is not feature envy merely because it reads several objects. Its responsibility is precisely the relationship among them.

This is why “move methods to the object whose fields they use” is too mechanical. The right destination may be:

  • the object whose invariant the behavior protects;
  • a value object representing the concept being calculated;
  • a domain policy that coordinates several domain values;
  • an application service when the work is primarily orchestration;
  • an adapter when the behavior belongs to an external boundary.

Choose ownership by meaning and reason to change, not by field count alone.

Use change patterns as evidence

Feature envy often becomes obvious in version history and code review.

Look for patterns such as:

  • changing one object’s representation repeatedly requires edits in another module;
  • several callers reconstruct the same decision from getters;
  • a service contains long chains of other.getX() calls followed by domain calculations;
  • an object’s invariants are enforced by callers rather than by operations on the object;
  • a supposedly reusable helper needs intimate knowledge of one particular domain type.

These are signals, not automatic refactoring instructions.

Before moving behavior, imagine the next plausible change. If a rule changes, which component should a developer naturally open first? If the proposed move makes that answer clearer and reduces duplicated knowledge, it is probably improving ownership. If it merely shifts code while dependencies remain the same, reconsider the change.

Put behavior where its knowledge belongs

Feature envy is useful because it exposes misplaced knowledge. A method can compile, pass tests, and still live behind a boundary that makes future changes unnecessarily coordinated.

When you find behavior that understands another component’s rules in detail, identify the complete decision it is making. Move that decision toward the state or concept it governs when doing so improves cohesion. Pass external facts in rather than pulling infrastructure into the new owner, and keep orchestration outside when several systems must participate.

Do not optimize for objects with the most methods or the fewest getters. Optimize for a design in which developers can answer a more practical question: where should this rule change? When the behavior and the knowledge it depends on have a clear shared home, that answer becomes easier.