A method can look perfectly reasonable while depending on far more knowledge than it should.

Imagine checkout code that asks a customer object for membershipLevel, joinedAt, and totalOrders, then combines those values to decide whether the customer receives priority support. The calculation works, but checkout now knows both the customer’s data and the rule that gives that data meaning.

When the rule changes, every caller that reconstructed it becomes a possible edit site.

A useful design principle is to put a decision close to the information needed to make it. Instead of repeatedly extracting raw state and interpreting it elsewhere, callers ask a focused question such as customer.isEligibleForPrioritySupport() or delegate to a policy that owns the relevant rule.

This article explains how to recognize misplaced decisions, move them toward the right owner, and avoid turning the principle into a rule that hides useful data or creates oversized objects.

Start with the knowledge a caller must possess

Consider this simplified pricing code:

if account.plan == "pro" and account.activeSeats >= 20:
    discount = 0.10
else:
    discount = 0

The caller needs three pieces of knowledge:

representation: plan uses the value "pro"
threshold:      20 active seats
policy:         that combination earns a 10% discount

Only the final result is relevant to the caller. Yet the caller must understand internal account facts and how to combine them.

Now suppose several workflows need the same decision:

invoice generator -> inspect plan + seats -> calculate discount
quote service     -> inspect plan + seats -> calculate discount
renewal preview   -> inspect plan + seats -> calculate discount

Each workflow can drift when the rule changes.

A better boundary gives the decision one home:

account.discountRate()

or, if pricing policy is deliberately separate from the account:

pricingPolicy.discountRateFor(account)

The important idea is not the exact class or method. It is the direction of knowledge:

weak boundary:
caller obtains facts -> caller interprets facts -> caller decides

stronger boundary:
caller asks question -> owner interprets its facts -> owner answers

The caller now depends on the meaning it needs rather than the representation used to derive that meaning.

Data access is not the problem by itself

It is easy to turn this idea into an unhelpful slogan: never use getters, never expose fields, or always put behaviour on an object.

Those rules are too broad.

Reading data is normal. A serializer may need values to produce JSON. A reporting pipeline may intentionally transform records. A user interface may need a display name and timestamp. A diagnostic tool may need observable state precisely because its job is inspection.

The stronger signal is repeated interpretation.

Suppose a caller does this:

name = customer.preferredName
if name is empty:
    name = customer.legalName

If one presentation screen does this once, the design may be acceptable. If email, invoices, support tools, audit messages, and notifications all repeat the same fallback, those callers collectively own a customer-name decision.

The question to ask is not:

Is this code reading another object’s data?

Ask instead:

Is this code using another object’s data to reconstruct a decision that concept should already know how to answer?

That distinction keeps the principle practical.

Look for code that reaches in and then decides

Misplaced behaviour often has a recognizable shape:

valueA = thing.getA()
valueB = thing.getB()
valueC = thing.getC()

if valueA ... and valueB ...:
    result = ... valueC ...

The method doing the work may live in a service, controller, helper, or unrelated domain object. Its location matters less than the fact that it is unusually interested in someone else’s state.

This pattern is commonly associated with the code smell called feature envy: code appears more interested in another object’s information than in its own responsibility.

The smell is evidence, not a verdict. Sometimes the external code is exactly where the behaviour belongs. But it is worth asking which component has the information and which component should own the decision.

For example:

function canCancel(order):
    return order.status == "pending"
        and order.paymentState != "refunded"
        and not order.shipped

This helper knows three details about an order and combines them into one domain-level answer. If cancellation eligibility is fundamentally an order rule, the order may be a clearer owner:

order.canBeCancelled()

The caller no longer needs to understand the status combination.

Move the question, not merely the code

A mechanical refactoring can relocate lines without improving the interface.

Suppose this logic starts in a service:

if order.status == "paid" and order.items.length > 0:
    ...

Moving it into an object as this method is only a partial improvement:

order.getStatusAndItemCount()

The caller still receives representation details and still makes the decision.

A stronger change names the question the caller actually needs answered:

order.isReadyForFulfilment()

This changes the contract from data retrieval to meaningful behaviour.

The distinction matters because a good operation can protect future implementation changes. The order might later determine readiness from payment authorization, inventory reservation, or another state. Callers that ask isReadyForFulfilment() do not need to change merely because the internal evidence changes.

A useful sequence is:

1. identify the decision
2. name the question
3. identify the information required
4. choose the owner of that knowledge
5. let callers depend on the answer

Naming the question before choosing a class often prevents a vague method such as check(), validate(), or process() from becoming the new abstraction.

Decide whether the entity or a policy should own the rule

Putting decisions near data does not mean every rule belongs as a method on the data-holding object.

Consider shipping cost. An order contains destination, weight, and items, but the calculation may also depend on carrier contracts, service levels, warehouse location, and frequently changing commercial rules.

Putting all of that inside Order could make the object depend on concepts it should not own.

A separate policy can still keep knowledge well placed:

ShippingPolicy.quote(order, destination, serviceLevel)

The policy owns the shipping decision. The order supplies information it legitimately owns.

A practical ownership test is to ask:

  • Does this decision describe an intrinsic capability or invariant of the object?
  • Does the rule mostly depend on information the object already owns?
  • Would placing the rule there introduce unrelated dependencies?
  • Does the rule vary independently from the object’s lifecycle?
  • Is the rule better understood as organizational, contractual, or configurable policy?

For example, order.canAddItem() may naturally belong to an order because it protects the order’s valid state. A promotional discount based on a campaign calendar may belong to a promotion policy because the campaign changes independently from any individual order.

The goal is coherent ownership, not maximum behaviour per object.

Protect invariants by exposing operations

The principle becomes especially valuable when state has rules that must remain true.

An invariant is a condition that should hold whenever an object is in a valid observable state. For example, an inventory quantity might never be negative.

A weak interface exposes state for callers to manipulate:

current = item.quantity
item.quantity = current - requested

Every caller must remember to check whether enough inventory exists. Two callers may implement the check differently. Another may forget it entirely.

An operation can keep the rule with the state it protects:

item.reserve(requested)

Conceptually, the operation can enforce:

requested must be positive
requested must not exceed available quantity
on success, quantity decreases by requested

Now callers do not perform a read-modify-write sequence themselves. They request a valid state transition.

This has an important consequence: the object can refuse invalid transitions at its boundary instead of relying on every caller to behave correctly.

The same idea appears in many domains:

weak:    account.balance = account.balance - amount
strong:  account.withdraw(amount)

weak:    task.status = "completed"
strong:  task.complete()

weak:    cart.items.append(item)
strong:  cart.add(item)

The stronger operations are useful only when they actually enforce meaningful rules. Replacing public fields with trivial setters such as setStatus() adds ceremony without protecting a decision.

Avoid read-modify-write APIs when the operation matters

An interface that requires callers to read state, calculate a new value, and write it back often leaks both knowledge and coordination responsibility.

Consider a retry counter:

count = job.retryCount
job.retryCount = count + 1

If the meaningful action is “record a retry,” expose that action:

job.recordRetry()

This can centralize related behaviour such as maximum-attempt checks or timestamps if those belong to the same concept.

There is also a concurrency reason to distinguish operations from read-modify-write sequences. If state is shared, reading a value and later writing a derived value can lose another update unless the storage mechanism provides appropriate synchronization or atomicity.

A domain-level operation does not automatically solve concurrency. An in-memory recordRetry() method can still participate in a race if multiple threads mutate the same object without coordination. But the operation gives the implementation a better boundary at which to apply the required concurrency mechanism. Callers no longer dictate the update algorithm.

So the design benefit is not “methods make updates atomic.” The benefit is that callers express intent while the owner retains control over how that intent is implemented.

Return answers at the level the caller needs

A good boundary should not force callers to decode an answer.

Suppose authorization code asks a document for several facts:

ownerId = document.ownerId
visibility = document.visibility
archived = document.archived

Then it decides whether editing is allowed.

Moving all authorization into Document may be wrong because permissions also depend on the acting user and organizational policy. But returning even more document fields is not the only alternative.

The document can expose domain facts at a useful level:

document.isArchived()
document.isOwnedBy(userId)

while an authorization policy owns the broader decision:

permissionPolicy.canEdit(user, document)

This illustrates an important nuance: information ownership can be layered.

Document owns document facts.
PermissionPolicy owns permission rules.
Caller asks PermissionPolicy for the final decision.

The best design is not necessarily the one with the fewest method calls. It is the one where each participant knows facts and rules appropriate to its responsibility.

Watch for booleans that move the decision back out

Sometimes an attempted improvement still makes callers reconstruct policy.

Imagine these methods:

order.isPaid()
order.hasStockReserved()
order.isAddressVerified()

Then every caller writes:

if order.isPaid()
   and order.hasStockReserved()
   and order.isAddressVerified():
    dispatch(order)

The raw fields are hidden, but the dispatch rule is still duplicated.

If that combination defines one stable domain question, a higher-level operation may be clearer:

order.isReadyForDispatch()

Lower-level queries can remain useful for diagnostics or other independent decisions. The issue is not that boolean methods are bad. The issue is that callers repeatedly compose the same booleans into the same meaning.

Whenever several callers use the same sequence of queries, ask whether the combination itself is the missing concept.

Do not hide data needed for legitimate integration work

Encapsulation has costs when taken too far.

A persistence mapper may need to reconstruct stored state. A serializer may need fields to cross a process boundary. Analytics may intentionally operate on data rather than domain behaviour. Debugging and observability often require state to be inspectable.

Trying to force every use through behavioural methods can create awkward APIs such as dozens of narrowly tailored methods whose only purpose is to avoid exposing harmless data.

Instead, distinguish roles.

A domain-facing interface can emphasize meaningful operations, while infrastructure code uses an explicit representation or mapping boundary:

Domain model <-> persistence mapper <-> stored record
Domain model <-> serializer        <-> API representation

This makes data exposure deliberate rather than accidental.

It also prevents infrastructure concerns from dictating the domain interface. A database column existing does not mean every business caller needs a getter for that column.

Avoid the god-object failure mode

Moving behaviour toward information can produce the opposite problem if every related operation accumulates on one object.

Suppose Customer gradually gains methods for billing, recommendations, fraud scoring, shipping estimates, email formatting, loyalty campaigns, tax rules, and support routing because all those features use customer data.

The result is not high cohesion. It is a central object with many unrelated reasons to change.

Data proximity is only one design force. Responsibility still matters.

A better decomposition may look like:

Customer
  owns identity and customer lifecycle rules

LoyaltyPolicy
  owns loyalty qualification

TaxPolicy
  owns tax decisions

RecommendationService
  owns recommendation logic

These collaborators can receive customer information through focused operations or explicit inputs.

The decision should move toward the component with the right knowledge and responsibility, not automatically toward whichever object contains the most raw fields.

Refactor one decision at a time

This design improvement works well as a small refactoring.

Suppose three callers calculate whether an order can be cancelled.

A controlled sequence is:

  1. Confirm that the callers really implement the same rule.
  2. Add or identify tests that capture current cancellation behaviour.
  3. Name the domain question, such as canBeCancelled().
  4. Put the rule on the appropriate object or policy.
  5. Change one caller to use the new operation.
  6. Verify behaviour is unchanged.
  7. Move the remaining callers.
  8. Remove obsolete duplicated interpretation.

Do not combine the structural move with a policy change unless necessary. If cancellation rules are changing too, first centralize the current rule, verify it, and then change the authoritative implementation.

Separating those steps makes failures easier to diagnose.

Use a practical review test

When reviewing code, find a block that reads several values from another component and ask:

What question is this code trying to answer?

Who has the information needed to answer it?

Who should own the rule that combines that information?

Would another caller need to repeat this interpretation?

If the caller is performing legitimate transformation or orchestration, leave it there.

If it is reconstructing another concept’s rule, consider moving the question toward the owner.

Then check the proposed abstraction in the opposite direction:

Does this new owner now depend on unrelated concepts?
Does the operation protect a real rule or merely wrap a field?
Will callers receive a meaningful answer rather than different raw pieces?

These questions prevent the refactoring from becoming mechanical encapsulation.

Conclusion

Good boundaries reduce how much one part of a system must know about another.

When callers repeatedly extract another component’s state and combine it into the same decision, the code is telling you that knowledge may be in the wrong place. Move the question toward the component that has both the relevant information and the responsibility to interpret it.

That may mean an entity method, a focused policy, or another domain boundary. It does not mean hiding every field or putting every rule on one object.

The practical goal is simpler: let callers express what they need, and let the right owner decide how that answer is derived. When the underlying representation or rule changes, fewer callers need to know why.