A function often starts in a reasonable place and becomes awkward as the system grows. It reads several fields from another object, interprets those fields, applies rules to them, and repeats the same pattern whenever a new requirement appears.
The problem is not simply that the function is long. The deeper problem is responsibility placement: one part of the system owns the data, while another part knows too much about what that data means.
This creates a fragile relationship. A change to the data representation can force changes in distant code, and the same business rule may be reconstructed differently by several callers.
A useful design heuristic is to move behavior closer to the data and rules it depends on. This article shows how to recognize that situation, refactor it safely, and decide when moving behavior would make the design worse rather than better.
Think in knowledge, not file location
The simplest mental model is:
where is the knowledge? -> where should the decision live?Suppose an order exposes its items and a reporting service calculates the total:
function orderTotal(order):
total = 0
for item in order.items:
total = total + item.unitPrice * item.quantity
return totalThe calculation is short, but notice what the reporting service must know:
- an order contains items;
- each item has a unit price;
- each item has a quantity;
- line value is
unitPrice * quantity; - order total is the sum of those values.
The service is not merely reading data. It understands the structure and meaning of another component’s state.
If Order is the abstraction responsible for order contents, a clearer interface may be:
function Order.total():
total = 0
for item in items:
total = total + item.unitPrice * item.quantity
return totalThe reporting code then asks for the result:
amount = order.total()Nothing about the arithmetic changed. What changed is who owns the knowledge required to perform it.
Why repeated data access is a useful signal
Accessing another object’s data is not automatically a design problem. Software components must collaborate.
The stronger signal is a cluster of accesses followed by a decision based on their meaning. For example:
if account.status == "active" and
account.balance >= amount and
not account.withdrawalsFrozen:
...A caller that performs this check must understand three pieces of account state and how they combine into a withdrawal rule. If several callers reproduce that logic, the rule has multiple owners in practice.
A more focused interface could express the decision directly:
if account.canWithdraw(amount):
...Now the caller knows what it needs to decide—whether withdrawal is allowed—without knowing every fact used to make that decision.
This reduces knowledge coupling. The caller still depends on the account’s public behavior, but it no longer depends on the exact representation of the rule.
That distinction matters during change. If a new rule says that restricted accounts may withdraw up to a daily limit, code using canWithdraw can remain unchanged while the account’s decision logic evolves in one place.
Move the smallest coherent responsibility
A common mistake is to notice misplaced behavior and move an entire workflow into one object. That can replace scattered logic with an object that knows too much about everything.
Instead, identify the smallest decision that is strongly tied to the data.
Imagine checkout code like this:
subtotal = cart.subtotal
if customer.memberYears >= 5:
discount = subtotal * 0.10
else:
discount = 0
amountDue = subtotal - discountThere are at least two separate concerns here:
- determining a customer’s discount policy;
- calculating the final checkout amount.
If membership rules belong to the customer model, move only that knowledge:
function Customer.discountRate():
if memberYears >= 5:
return 0.10
return 0Checkout can still coordinate the transaction:
subtotal = cart.subtotal
amountDue = subtotal * (1 - customer.discountRate())This is usually a better move than putting the whole checkout process on Customer. The customer owns membership knowledge; checkout owns the interaction between cart, customer, payment, and other participants.
The goal is not to make every object perform every operation involving its data. The goal is to place each decision where its required knowledge is most naturally owned.
Distinguish decisions from orchestration
This heuristic becomes clearer when you separate decision logic from orchestration.
Decision logic answers questions such as:
Is this order eligible for cancellation?
What is this invoice's outstanding amount?
Does this subscription permit another user?These questions often depend strongly on one abstraction’s state and rules. They are good candidates to live with that abstraction.
Orchestration coordinates several participants:
load order
check cancellation eligibility
refund payment
release inventory
send notificationNo single domain object necessarily owns that whole sequence. A service or application-level workflow may be the right place to coordinate it.
This distinction prevents a misleading rule such as “put every function on an object whose data it touches.” Real workflows often use data from several sources. Forcing them into one participant can create an arbitrary dependency and hide the actual coordination.
Refactor by following the dependencies
When moving behavior in existing code, first identify what the behavior actually needs.
Suppose a shipping helper contains:
function shippingCharge(package):
if package.weightKg <= 2:
return 5
return 5 + (package.weightKg - 2) * 1.5The function depends only on package weight and a shipping rule. Before moving it, ask two questions.
First: Who owns the rule? If every package uses this pricing policy, the behavior may belong on Package or on a shipping-policy abstraction associated with it. If rates vary by carrier, destination, or contract, putting the rule directly on Package would give the package knowledge it should not own.
Second: What dependencies would the move introduce? A method that needs a database, clock, network client, or unrelated service may become harder to understand if those dependencies are pulled into a simple data-focused object merely to relocate the method.
A safe refactoring sequence is:
- identify the inputs the behavior reads;
- identify the rules it applies;
- choose the abstraction that should own those rules;
- move the smallest coherent behavior;
- replace callers with the new operation;
- remove old accessors only if they are no longer useful elsewhere.
The important part is step three. Mechanical proximity is not enough. Ownership of meaning is what matters.
Watch for representation leaking through getters
Encapsulation can look strong while still leaking representation.
Consider an object with private fields but many getters:
invoice.getSubtotal()
invoice.getTaxRate()
invoice.getCreditAmount()
invoice.getCurrency()If callers repeatedly combine those values to answer “how much is due?”, the getters have not protected the rule. They have only protected direct field access.
A higher-level operation can expose the useful concept instead:
invoice.amountDue()This gives the invoice room to change how the amount is represented or calculated without requiring every caller to understand the change.
The point is not to eliminate getters. Getters are appropriate when callers genuinely need the underlying information—for display, export, diagnostics, or other independent operations. The warning sign is when callers retrieve several values only to reconstruct a concept the owning abstraction could express directly.
Do not hide necessary context
Moving behavior closer to data has limits. Some decisions legitimately depend on context that the data owner should not know.
For example, whether an order can be shown with a promotional badge might depend on:
- order value;
- the current marketing campaign;
- the user’s region;
- an experiment assignment.
Putting shouldShowPromotionBadge() on Order would require the order to understand marketing and experimentation concerns. That increases coupling rather than reducing it.
A separate policy can be clearer:
promotionPolicy.shouldShowBadge(order, campaign, region)Here, reading order data from outside is reasonable because the decision belongs to a different concern.
This is the central trade-off: moving behavior inward reduces representation knowledge in callers, but moving unrelated policy inward makes the data owner depend on concerns outside its responsibility.
Avoid turning objects into question-answering APIs
Another failure mode is creating a method for every question any caller might ask:
order.isLargeForDashboard()
order.isInterestingForAudit()
order.isEligibleForCampaignA()
order.shouldUseWarehouseScreenColor()These methods may reduce field access, but they make Order responsible for dashboards, auditing, campaigns, and warehouse presentation.
A useful test is to ask whether the behavior describes the abstraction itself or merely one consumer’s use of it.
order.isCancellable() can reasonably describe an order lifecycle rule.
order.shouldAppearInRedOnDashboard() describes presentation policy and probably belongs elsewhere.
Behavior placement is about cohesive responsibility, not maximizing the number of methods attached to a data type.
Consider immutable data and functional designs
The same principle applies even when the codebase does not use objects with methods.
In a functional design, “closer to the data” can mean placing a function in the module that defines and owns the data model and its invariants:
Order.total(order)
Order.canCancel(order, now)The key property is not object-oriented syntax. It is that callers depend on a stable operation rather than duplicating knowledge of representation and rules.
For simple immutable records that intentionally have no behavior, separate pure functions may be the clearest design. Moving functions into methods purely to follow an object-oriented convention would add ceremony without improving ownership.
Know when the simpler design is better
Do not refactor every small calculation into a new abstraction.
Leaving behavior where it is can be reasonable when:
- the calculation is specific to one caller;
- the data is intentionally a simple transfer or reporting structure;
- the rule belongs to a separate policy rather than the data owner;
- moving it would introduce unrelated dependencies;
- the behavior is trivial and has no realistic reuse or change pressure.
For example, formatting a person’s name for one report does not necessarily belong on Person. The report may have a presentation-specific ordering and punctuation rule. Moving that rule to the person model would mix reporting policy with person data.
Refactoring has a cost. Use this technique when it reduces duplicated knowledge or makes a responsibility boundary clearer, not merely because a function reads another object’s fields.
Use change as the final test
A practical way to evaluate behavior placement is to imagine a likely rule change.
Suppose the definition of an order total changes to include a handling charge. Ask:
How many places must understand that new rule?
If several callers each calculate the total from raw fields, the change spreads. If callers ask the order for its total, the rule can change behind one stable operation.
Then test the opposite direction. Suppose promotional eligibility changes. If that rule lives on Order even though it depends on campaign configuration, changing marketing policy may force changes to the order model. That is a sign the behavior moved too far inward.
Good placement tends to localize changes according to their reason. Order rules change with order rules. Marketing rules change with marketing rules. Coordination changes with workflow changes.
Conclusion
When code repeatedly pulls data from another abstraction and interprets what that data means, look beyond the individual getters or conditionals. The real issue may be that knowledge and behavior have been separated unnecessarily.
Move the smallest coherent decision toward the component that owns the data and rules it needs. Keep orchestration outside when it coordinates several participants, and keep consumer-specific policy outside when the data owner should not know about that concern.
The practical test is simple: place behavior where a future change to the underlying rule can be made with the fewest unrelated parts of the system needing to understand it.