A method can belong to one module while doing most of its thinking with data owned by another. When that happens repeatedly, a small change to the data often forces changes in distant code as well.
This design smell is commonly called feature envy: behavior appears more interested in another object or module than in the one that contains it. The useful lesson is broader than the name. When a piece of behavior depends heavily on particular data and the rules around that data, keeping them close usually makes those rules easier to find, protect, and change together.
This article shows how to recognize that situation, decide whether behavior should move, and avoid the opposite mistake of forcing behavior into objects that should remain simple data.
Start with what the behavior needs to know
Consider an order with line items:
Order
items
customerTierA separate checkout service calculates the subtotal like this:
subtotal = 0
for item in order.items:
subtotal += item.unitPrice * item.quantityThis code is not wrong merely because it reads order.items. Services often need information from the objects they coordinate.
The more useful question is: who owns the rule being implemented?
The loop knows that a line item’s monetary contribution is unitPrice * quantity. If that rule is part of what a line item means, the calculation can live with the line item:
LineItem.total():
return unitPrice * quantityThe checkout code becomes:
subtotal = 0
for item in order.items:
subtotal += item.total()The arithmetic did not become more sophisticated. What changed is where knowledge lives. The checkout service no longer needs to know which fields determine a line item’s total.
That matters when the rule changes. Suppose some line items can represent a fixed package price. With the first design, every caller that reproduces unitPrice * quantity may need to change. With the second, callers still ask the line item for its total while the rule changes in one place.
Use knowledge as the boundary
A common but weak rule is “objects should never expose data.” Real programs need to read data, serialize it, display it, compare it, and send it across boundaries. Getters are not automatically a design problem.
Instead, look for knowledge duplication.
Suppose several modules contain code like this:
if order.customerTier == "gold" and order.subtotal() >= 100:
discount = order.subtotal() * 0.10Those modules do not merely read an order. They know a business rule involving customer tier, a threshold, and a percentage. If that rule conceptually belongs to the order’s pricing behavior, callers can ask a more meaningful question:
discount = order.discount()Now the caller depends on the result it needs rather than the fields and conditions used to derive it.
The mental model is:
Put a rule near the data whose meaning determines that rule, unless another boundary has a stronger reason to own it.
The final clause is important. Data proximity is evidence, not proof of ownership.
Distinguish calculation from coordination
Moving every operation into the object that contains some related data creates a different problem. Many operations span several responsibilities and should remain coordination logic.
Imagine checkout needs to:
- calculate the order total;
- reserve inventory;
- charge a payment provider;
- send a receipt.
The order can reasonably own calculations based on its own state. It should not necessarily own calls to inventory, payment, and messaging systems just because checkout starts from an order.
A useful split is:
Order
subtotal()
discount()
total()
CheckoutService
asks Order for total
reserves inventory
charges payment provider
sends receiptThe order contains rules about what an order means. The service coordinates work across boundaries.
This distinction reduces two kinds of coupling. The service does not reproduce pricing internals, and the order does not become dependent on infrastructure that is unrelated to its core rules.
Recognize stronger signs of feature envy
One field access is weak evidence. A cluster of knowledge is stronger evidence.
Consider a report formatter:
formatAddress(customer):
return customer.address.street
+ ", " + customer.address.city
+ " " + customer.address.postalCodeWhether this behavior should move depends on what the formatting means.
If that exact representation is the canonical postal representation of an Address, an Address.formatPostal() operation may be appropriate. The address owns the fields and the rule connecting them.
If the output is specific to one report, however, moving it into Address would make the domain object know about a presentation concern. A report formatter is then the better owner even though it reads several address fields.
Ask three questions together:
- Does the behavior use several details from another component?
- Would a change to those details usually require this behavior to change?
- Is the behavior part of that component’s meaning rather than a caller-specific presentation or workflow?
When all three answers are yes, moving the behavior is often worth considering.
Move behavior in small steps
A safe refactoring preserves behavior while changing ownership.
Suppose InvoiceService contains:
lateFee(invoice, today):
if today <= invoice.dueDate:
return 0
return invoice.outstandingBalance * invoice.lateFeeRateAssume the late-fee rule is intrinsic to the invoice rather than a policy selected by the service. A small refactoring is:
Invoice.lateFee(today):
if today <= dueDate:
return 0
return outstandingBalance * lateFeeRateThen the service delegates:
fee = invoice.lateFee(today)Notice that today remains an argument. Moving behavior close to its data does not require hiding every dependency. The invoice owns its balance, due date, and rate, but the current date comes from outside. Passing it explicitly keeps the calculation deterministic and avoids making the invoice responsible for obtaining time.
For production code, existing tests should protect the behavior during the move. If coverage is weak and the code is risky to change, characterize the current behavior first rather than combining a structural refactoring with a rule change.
Preserve invariants, not just convenience
The strongest reason to keep behavior near data is often an invariant: a condition that must remain true for an object or operation to be valid.
Suppose callers update reservation capacity directly:
reservation.remainingSeats -= requestedSeatsEvery caller now has to remember that remaining seats must not become negative. A behavior-oriented operation can keep the rule with the state:
Reservation.reserve(requestedSeats):
if requestedSeats <= 0:
reject request
if requestedSeats > remainingSeats:
reject request
remainingSeats -= requestedSeatsThis change does more than shorten callers. It narrows the ways state can change. If mutation goes through reserve, the reservation has a natural place to enforce the conditions required for that transition.
The guarantee is only as strong as the boundary. If callers can still modify remainingSeats directly, reserve does not protect the invariant. Encapsulation requires controlling the relevant mutation paths, not merely adding a convenient method.
Do not turn passive data into artificial objects
Some data genuinely exists to cross a boundary. A parsed configuration record, an API request shape, an event payload, or a database transfer structure may have little behavior of its own.
Adding methods merely to avoid field access can make those structures harder to understand without protecting any meaningful rule.
For example:
SearchRequest
query
page
pageSizeA controller reading those fields to call a search use case is ordinary boundary mapping. SearchRequest.executeSearch() would mix transport data with application behavior and create a less useful abstraction.
A simpler data structure is often better when:
- the structure primarily carries data between boundaries;
- different consumers legitimately interpret the data differently;
- there is no invariant or domain rule to protect;
- moving the behavior would introduce dependencies on unrelated infrastructure or presentation concerns.
The goal is not to maximize the number of methods on objects. It is to place important knowledge where changes to that knowledge can be contained.
Watch for behavior that belongs to a separate concept
Sometimes feature envy reveals a missing abstraction rather than a method that should move directly onto an existing object.
Consider discount logic that depends on an order, a customer agreement, and a promotion:
calculateDiscount(order, agreement, promotion)Putting this method on Order may be arbitrary because no single input owns the complete rule. If the calculation is substantial and changes independently, a DiscountPolicy can express the concept more clearly:
DiscountPolicy.calculate(order, agreement, promotion)This is still behavior close to the knowledge it needs, but “close” now means inside the abstraction that represents the rule, not physically attached to one data holder.
This option is especially useful when several valid policies can exist. The caller can select a policy while each policy owns its own decision rules.
Avoid mechanical delegation
A refactoring has failed if it only hides a long access chain behind a method with no meaningful responsibility.
For example, replacing:
order.customer.address.countrywith:
order.customerCountry()may reduce visible navigation, but it does not automatically improve the design. If Order has no reason to own customer geography, it has become a forwarding layer that still knows about the same structure.
Prefer operations that express intent:
shippingPolicy.isEligible(order)or, when the rule belongs to the order:
order.isEligibleForDomesticShipping()Which form is appropriate depends on who owns the rule. The important improvement is not fewer dots. It is less duplicated knowledge about how to reach data and what that data means.
Evaluate the trade-off after the move
Moving behavior can improve locality, but it can also enlarge an object’s responsibility. Review the result rather than assuming the move was correct.
A useful change should usually make at least one of these things easier:
- finding the rule when the underlying data changes;
- enforcing an invariant through a controlled operation;
- testing a calculation without unrelated infrastructure;
- changing internal representation without updating many callers;
- understanding a caller because it expresses intent instead of reconstructing a rule.
Reconsider the move if the receiving object now depends on unrelated systems, presentation formats, or workflow concerns. Also reconsider it if many unrelated operations accumulate on one object simply because they happen to consume its fields.
Cohesion is the target: behavior and data that change for the same reason should be easy to find together.
Conclusion
Feature envy is useful because it points to misplaced knowledge, not because every external field access is wrong.
When code repeatedly inspects another component’s data to implement rules about that data, ask whether the behavior can move closer to the rule it represents. Doing so can reduce duplicated knowledge, protect invariants, and make representation changes less disruptive.
Keep coordination at coordination boundaries, leave passive transfer data simple when that is its real purpose, and introduce a separate policy or domain concept when no existing object clearly owns the rule.
The practical test is straightforward: if this data or rule changes, where would a developer naturally look first? A maintainable design tries to make that place the same place where the relevant behavior lives.