A method can live in the wrong place even when its code is correct. One common sign is a method that repeatedly reads another object’s fields, interprets those values, and makes a decision that is really about that other object.

This creates a maintenance problem. The data and the rules governing that data change for related reasons, but the code is stored in different places. A small change to the meaning of the data can then require developers to find and update distant callers.

This article develops a practical mental model for keeping behavior close to the data it uses. You will learn how to recognize the problem often called feature envy, how to move a decision to a better boundary, and when leaving behavior outside the data-owning object is the simpler design.

Look for code that knows too much about another object

Consider an order with a few facts:

Order:
    subtotal
    customerTier
    destinationCountry

A shipping service decides whether the order qualifies for free shipping:

shippingCost(order):
    if order.customerTier == "premium" and order.subtotal >= 50:
        return 0

    if order.subtotal >= 100:
        return 0

    return standardRate(order.destinationCountry)

The service needs to calculate shipping, so this location initially looks reasonable. But notice what the first two conditions do: they inspect order data and decide whether the order qualifies for free shipping. That decision depends almost entirely on facts owned by Order.

The service is not merely using the order. It understands a rule about the order’s state.

This is the useful meaning of feature envy: code in one module or object is more interested in another object’s data than in its own. The name is less important than the design signal. When a caller must know several details of another object to make a decision, the boundary may be exposing data where it could expose behavior instead.

Ask for the answer instead of reconstructing it

The smallest refactoring is to move only the decision:

Order:
    qualifiesForFreeShipping():
        if customerTier == "premium" and subtotal >= 50:
            return true

        return subtotal >= 100

The shipping service becomes:

shippingCost(order):
    if order.qualifiesForFreeShipping():
        return 0

    return standardRate(order.destinationCountry)

The important change is not the reduction in lines. It is the change in knowledge.

Before the refactoring, shippingCost knew the premium threshold, the general threshold, and which order fields determined eligibility. After the refactoring, it knows only that an order can answer whether it qualifies. The rule and the data used by the rule now have one reason to change together.

This is a form of encapsulation. Encapsulation is not just hiding fields behind getters. A stronger boundary hides the interpretation of those fields as well.

Follow the knowledge, not the nouns

A tempting rule is “put every operation on the object whose name appears in the operation.” That is too mechanical.

The better question is:

Which component has the information and responsibility needed to make this decision without learning unnecessary details about another component?

In the example, free-shipping eligibility depends on subtotal and customerTier, both facts already held by the order. Moving that decision to Order reduces the amount of order-specific knowledge in the shipping service.

But the final shipping price also depends on standardRate(destinationCountry). If that rate comes from a carrier contract, configuration service, or frequently changing external table, forcing the whole shipping calculation into Order would give the order a new external dependency. That would move unrelated knowledge in the wrong direction.

So the useful boundary is:

Order -> decides eligibility from its own facts
ShippingService -> combines eligibility with external shipping rates

Keeping behavior close to data does not mean concentrating all behavior into one object. It means placing each decision where its required knowledge is most naturally owned.

Why repeated getters are a useful warning sign

Getters are not inherently a design problem. Callers often need raw data for serialization, display, reporting, or integration boundaries.

The warning appears when several callers repeatedly perform the same interpretation:

if account.balance < 0 and account.overdraftLimit == 0:
    ...

Suppose billing, notifications, and account management all contain versions of that condition. Each caller now knows how balance and overdraftLimit combine to define a restricted account.

If the rule changes to include a grace period, every copied interpretation becomes a possible defect.

A behavior-oriented boundary can centralize the meaning:

Account:
    isRestricted():
        return balance < 0 and overdraftLimit == 0

Now callers depend on the concept isRestricted, not on the current representation of restriction.

This also makes representation changes easier. If overdraft policy later uses a limit object rather than a number, callers that ask isRestricted() do not need to know.

Move one coherent decision at a time

Feature envy can tempt a large redesign. Usually, a smaller refactoring is easier to reason about.

Start with one method that reads another object’s data heavily. Identify the smallest coherent decision inside it. Then ask what inputs that decision actually requires.

For example, suppose invoice code contains:

sendReminder(invoice, today):
    if invoice.status == "unpaid" and today > invoice.dueDate:
        email.send(invoice.customerEmail)

There are two different responsibilities here:

  1. deciding whether the invoice is overdue;
  2. sending an email.

Only the first decision belongs naturally with invoice state:

Invoice:
    isOverdue(today):
        return status == "unpaid" and today > dueDate

The workflow remains responsible for the effect:

sendReminder(invoice, today):
    if invoice.isOverdue(today):
        email.send(invoice.customerEmail)

Passing today explicitly is useful because the invoice does not need to acquire a clock dependency just to answer a business question. The object owns the rule; the caller supplies a fact from the outside world.

This separation keeps the refactoring focused and preserves a clear boundary between a decision and an effect.

Check whether the move actually improves the design

A behavior move is useful when it reduces duplicated knowledge or makes a concept easier to change. It is not useful merely because a method became shorter.

After a move, check three things.

First, knowledge locality: does the rule now sit near most of the data and invariants it depends on? If the new method immediately reaches into several other objects, the move may only have relocated the problem.

Second, dependency direction: did the data-owning object gain dependencies on infrastructure or unrelated services? A domain object that now needs a database, HTTP client, or message broker to answer a simple state question may have become harder to use and test.

Third, caller simplicity: can callers ask a meaningful question without reconstructing the object’s internal rules? A method such as invoice.isOverdue(today) expresses more intent than several comparisons against invoice fields.

These checks matter more than whether the resulting design follows an object-oriented slogan.

Do not move behavior when the rule belongs elsewhere

Some operations intentionally use data from many sources. Pricing, authorization, scheduling, and orchestration often combine information that no single data object owns.

Imagine a discount decision that depends on an order, a customer loyalty program, a temporary promotion, and current inventory. Putting the whole calculation on Order would make the order responsible for concepts it does not own.

A dedicated policy object can be clearer:

DiscountPolicy:
    discountFor(order, customer, promotion, inventory):
        ...

The policy may still ask each object meaningful questions rather than reading all of their fields. The key point is that the cross-object decision itself has a legitimate home.

Likewise, simple data structures at system boundaries do not need to become rich objects. A decoded message, database row, or API transfer object may exist mainly to move data. Adding behavior there can blur the boundary between transport representation and business meaning.

The goal is not “behavior must live with data.” The goal is to avoid scattering the meaning of data across unrelated callers.

Watch for methods that become data tourists

Moving a method can reveal a second problem. Suppose Order.qualifiesForFreeShipping() eventually grows to inspect customer history, warehouse region, campaign configuration, and carrier capacity.

The method started near its own data, but it has become a tourist across the rest of the system. Its responsibility has changed.

That is a signal to reconsider the boundary. Perhaps free-shipping eligibility is now a separate policy that receives the required facts explicitly. The correct location can change as the rule changes.

This is why feature envy is a diagnostic, not a law. It points to concentrated knowledge that deserves examination. It does not determine the final design automatically.

Prefer behavior that expresses a stable concept

A useful moved method usually names a concept that callers care about:

invoice.isOverdue(today)
order.qualifiesForFreeShipping()
account.isRestricted()

A weak move often just wraps access without hiding meaning:

order.getSubtotal()
order.getCustomerTier()

The getters may be necessary, but they do not reduce the caller’s knowledge of the rule. If every caller still combines them in the same way, the important behavior remains scattered.

At the other extreme, avoid vague methods such as order.process() that absorb unrelated decisions and effects. A good boundary hides a coherent rule while keeping responsibilities understandable.

Use the refactoring when change is scattered

Keeping behavior close to its data is especially useful when a business rule is repeated, callers inspect several fields to derive the same concept, or representation changes require edits across many files.

It can also help when tests for a simple rule require constructing unrelated services. Moving the decision toward its inputs often makes the rule independently testable without adding test-specific hooks.

A simpler design may be preferable when the logic is a one-off transformation, the data is intentionally passive at an integration boundary, or the decision genuinely combines several independent sources. In those cases, a small function or policy object can be clearer than adding behavior to a data-owning object.

Conclusion

When code repeatedly reads another object’s data and interprets it, ask whether the caller is carrying knowledge that belongs closer to that data.

Move the smallest coherent decision, not the entire workflow. Keep external effects and unrelated dependencies where they belong. Then check whether callers can depend on a meaningful concept instead of reconstructing the rule from fields.

The practical test is simple: when the meaning of the data changes, how many places need to understand that change? A well-placed behavior boundary keeps that number small without forcing unrelated responsibilities into the same object.