RefactoringPARADIGM-SPECIFICFRAMEWORK-SPECIFICCONTESTED

Move Responsibility

If a piece of logic spends its time reaching into another object's data, it probably belongs to that object. Moving it is usually the cheapest coupling reduction available.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

How do I tell whether a piece of logic is in the wrong place, and what does moving it actually buy?

The requirement

A shipping cost calculator reads six fields off Order and three off Address, and every time a field on Order is renamed or restructured, the calculator breaks. It has broken three times in six months.

The obvious build

Data classes hold data and services hold behaviour. The calculator reaching into Order is normal — that is what services do, and moving logic onto entities is old-fashioned object orientation.

Why it breaks

It makes the data shape a public contract. Nine fields reached from outside means nine things Order may never rename, restructure or make private, and the three breakages are exactly that (Exposing Too Much).

How it breaks as requirements change
  • It makes the data shape a public contract. Nine fields reached from outside means nine things Order may never rename, restructure or make private, and the three breakages are exactly that (Exposing Too Much).
  • It scatters the rules. If four services each reach into Order to decide something, the knowledge of what those fields mean lives in four places and none of them is Order (Duplicate Knowledge).
  • It defeats invariants. Order cannot guarantee anything about the relationship between its fields when everyone else computes on them directly (Invariant Leaks).
  • The convention argument is real but weaker than it sounds: "logic in services" is a coherent design when services own coherent responsibilities, and it degrades into a god service when they own whatever was convenient (The Anemic Domain Model).
  • And the ORM objection is a real constraint that points somewhere specific — it argues for a domain type that is not the entity, not for leaving the logic where it is (Entities).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The calculator is called from checkout, from the quote endpoint and from a nightly report.
  • Order is an ORM entity, so moving behaviour onto it couples domain logic to the persistence library (What an ORM Buys and What It Costs in Backend).
  • The team's style is thin data classes and logic in services, so moving behaviour onto an entity cuts against a convention.
Invariants
  • Whoever owns the data owns the rules about what makes it valid (State Ownership).
  • Moving behaviour preserves behaviour exactly, including how it fails.
  • After the move, no caller depends on data the moved logic used to reach for.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The object holding the data owns the questions that can be answered from that data alone: order.isEligibleForFreeShipping(), address.isDomestic().
  • A service owns coordination across things that no single object can see — pricing that needs the order, the customer's tier and the current promotions.
  • Nothing owns "logic in general". A service that exists because logic had to go somewhere is the shape this refactoring exists to fix (Domain Services).
Boundaries
  • The boundary is the data. Logic that only needs one object's data belongs with that object; logic that genuinely needs three does not belong to any of them and stays in a service (Domain Services).
  • The ORM boundary matters here: where the persistence type cannot safely carry behaviour, the move is onto a domain type constructed from it, which is a larger change and a legitimate reason to defer (Value Objects).
  • Move the responsibility, not the code. Sometimes the right move is not relocating the function but making the object answer a question, so the calculation disappears rather than relocating (Encapsulation).

The smell, and when it is not one

The classic name for this is feature envy: a method more interested in another object's data than its own. It is a useful heuristic precisely because it can be counted rather than felt.

It is also a question rather than a verdict, and there is a specific, common case where the code is exactly right as written.

smellFeature envyFeature envy in the shipping calculator

looks like A method that reads six fields from order and three from order.shipTo, and two from itself. if (order.total > 5000 && order.items.every(i => !i.hazardous) && order.shipTo.country === 'GB' && ...).

suggests The knowledge of what those fields mean together lives outside the object that owns them. Every field it reaches for is now part of Order's public shape and can never be renamed or restructured without breaking this caller — which is why the calculator broke three times in six months while nothing about shipping rules changed (Exposing Too Much).

fix Ask what question the condition answers, name it, and put it on the owner: order.qualifiesForFreeShipping(). Then check the direction — if the new method needs a rate table, a clock or a repository, the responsibility went to the wrong side and belongs back in a service with those dependencies injected (Volatile Dependencies).

when this is fine A reporting projection, a serializer or an anti-corruption mapper is *supposed* to know the shape of what it reads — that is its entire job, and pushing toCsvRow() onto Order would give the domain object a reason to change every time the finance team wants a new column (Divergent Change). The same applies to a genuine cross-object rule: pricing that needs the order, the customer tier and the active promotions is not envious of any one of them, and forcing it onto Order would drag two new dependencies into the domain model.

What the calculator is holding

Written out as a unit, the calculator has a mixture of things it legitimately owns and things it is borrowing, and the borrowed ones are precisely the reasons it keeps breaking.

responsibilitiesShippingCostCalculatorShippingCostCalculator, before the move
Knows
  • The carrier rate table and the surcharge rules — genuinely its own
  • That order.total is exclusive of tax, and that free shipping is judged on the exclusive figure
  • That order.items[].hazardous exists and what it implies for carrier choice
  • That order.shipTo.country is an ISO-2 code and which of them count as domestic
  • That order.customer.tier affects the free-shipping threshold
Does
  • Decides whether shipping is free
  • Chooses a carrier
  • Computes a cost
Depends on
  • Nine fields across three objects, by shape
  • The carrier rate table
  • The carrier API for surcharges
Changes when — 4 distinct reasons
  • A carrier rate changes — its own reason, and the only legitimate one
  • Order renames or restructures any of six fields
  • Address changes how country is represented
  • The definition of "eligible for free shipping" changes, which is a business rule about orders rather than about shipping

Four reasons to change, and three of them belong to somebody else. Only the first is about shipping. Moving qualifiesForFreeShipping onto Order and isDomestic onto Address removes three of the four, leaving a calculator that changes only when carrier rates change — which is what its name claims it is for. Note that the free-shipping rule moving to Order is not merely relocation: it is the discovery that the rule was always a fact about orders and had been living in the shipping module by accident (Single Responsibility, Carefully).

Pricing the move

The move is an hour of work. What it changes is which future edits are local, and — importantly — which ones become slightly worse.

`Order.total` is restructured to carry currency
The change

total: number becomes total: Money, with an explicit currency, because the business added a second currency.

Shipping, quoting and the nightly report each read `order.total` directly
OrderShippingCostCalculatorQuoteServiceNightlyRevenueReportInvoiceRenderer
testsorder_testshipping_testquote_testreport_testinvoice_test
5 modules · 5 test files

Five modules, five test files, and each site has to decide independently what the free-shipping threshold means in the new currency. Two of them will get it wrong and nothing will notice until a GBP order ships free.

`Order.qualifiesForFreeShipping()` answers the question; nothing outside reads `total`
Order
testsorder_test
1 module · 1 test file

The threshold comparison happens in one place, so the currency decision is made once and correctly. The calculator does not change at all, because it never knew what a total was.

what it cost The narrow interface is worse for anything that legitimately wants the raw data. The nightly revenue report does want order.total, arithmetic and all, and after this move it either gets an accessor anyway — which restores the coupling for that one caller, and is the right answer — or it grows a projection type of its own. There is also a real risk of overshooting: if Order acquires a method for every question anyone ever asks, the coupling has not gone away, it has been renamed and Order is on its way to being a god object (God Object).

How to build it

Most important first.

  • Count the reaches. A method that touches more of another object's data than its own is the classic signal, and it is a count rather than a feeling (Feature Envy).
  • Ask what question is being answered. order.total > 5000 && order.items.every(i => !i.hazardous) is order.qualifiesForFreeShipping() — the move is a naming discovery as much as a relocation (Naming).
  • Move in small steps: introduce the method on the owner, delegate to it from the old site, verify, then inline the old call and delete it (The Refactoring Loop).
  • Check the dependency direction afterwards. If Order now needs the shipping-rate table, you have moved the responsibility to the wrong side and coupled the domain to a rate source (Dependency Direction).
  • Where the ORM entity cannot hold behaviour, introduce a value object or a domain type and move the logic there instead of onto the entity (Value Objects).
  • Leave genuinely cross-object logic in a service, and name that service after the responsibility rather than after the noun it operates on (Domain Services).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: renaming a field on Order costs an edit in every place that reaches for it — the three breakages in six months are the measurement. The cost grows with every new service that learns the shape (Shotgun Surgery).
  • After: renaming a field costs one edit inside Order, because nothing outside it knows the field exists. The next change to the shipping rule costs one edit in the method that owns it.
  • What gets more expensive: a change that genuinely spans order, customer and promotion now has to go through three narrow interfaces rather than reading three objects' fields directly. Narrow interfaces cost something at exactly the moments they are protecting you.
  • The move itself is cheap — usually an hour — which is what makes this one of the better-value refactorings available (The Cost of Change).
What the recommended approach costs
  • Behaviour on data objects is contested style, and on a team with a strong service convention this refactoring costs an argument every time (The Anemic Domain Model).
  • Where the data object is an ORM entity, moving behaviour onto it couples domain rules to the persistence library, and the alternative — a parallel domain model — is a much larger commitment (What an ORM Buys and What It Costs in Backend).
  • Narrow interfaces make the anticipated changes cheap and genuinely make ad-hoc queries harder; the reporting job that wants to slice orders six ways is worse off (Cost-Aware Interfaces).

What can go wrong

Failure modes
  • Moving logic onto an object that then needs a repository, a clock or an HTTP client — a domain object with infrastructure dependencies is worse than the service it replaced (Volatile Dependencies).
  • Moving everything, producing a god entity with forty methods, which is the same problem relocated (God Object).
  • Moving logic that genuinely needed three objects, so the owner now reaches into the other two and the envy has simply changed direction.
  • The mitigation fails: a team introduces a domain type to avoid ORM coupling and ends up maintaining two parallel models with a mapping layer nobody wanted (Anti-Corruption Layer).
  • The move is made and the old accessors are left public, so nothing is actually encapsulated and the fragility remains.
Dependencies, and their direction
  • Before: the calculator depends on nine fields of two objects — a wide, fragile dependency on shape rather than on behaviour.
  • After: the calculator depends on two or three methods, which is a narrow dependency on meaning, and those methods can be reimplemented freely (Information Hiding).
  • The risk is the reverse dependency: if the moved logic needs something the owner does not have, moving it drags a new dependency into the owner and can make things worse (Fan-in and Fan-out).
Misreads
  • "So all logic belongs on entities." Logic that needs several objects belongs in a service. The criterion is whose data it uses, not a preference for one style (Domain Services).
  • "This is object orientation, so it does not apply to functional code." The same finding appears as a function taking one record and reaching into it repeatedly; the move is into the module that owns the type. The idiom changes and the coupling argument does not.
  • "Feature envy means the code is wrong." It means the code is somewhere surprising. Sometimes the reach is deliberate — a reporting projection is supposed to know the shape — and that case is genuinely fine (Feature Envy).
  • "Move it and the coupling is gone." Coupling was reduced in one direction. Check what the owner now depends on, because that is where this refactoring makes things worse (Kinds of Coupling).
Smells this explains
  • feature-envy
  • anemic-domain-model
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Test the new method on the owner directly; it should be trivially testable, because it needs only the object's own data. If it is not, the move was wrong (Testing as Design Feedback).
  • The caller's tests should not change, since behaviour did not (What Refactoring Actually Is).
  • Delete tests that were asserting on the raw fields from outside — those tests were encoding the coupling you just removed (What a Unit Is).
How this design ages
  • Objects that answer questions accumulate a vocabulary, and after a few moves the service layer shrinks into orchestration, which is what it should have been (The Anemic Domain Model).
  • The move sometimes reveals that the object is missing a concept entirely: shipping eligibility that needs weight, dimensions and hazard class is asking for a Shipment that does not exist yet (Domain Modeling).
  • What forces a rethink: a method that started needing only its own data grows to need a rate table or a clock, at which point it should move back out into a service — moving responsibility is not a one-way ratchet (Time as a Dependency).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • PARADIGM-SPECIFICStated in OO terms because that is where the smell was named, but the same finding appears in functional code as a function that pattern-matches deeply into a record it did not define — and the move there is into the module that owns the type, not onto an object. In a procedural codebase the equivalent is a function reaching into a struct across a module boundary. The mechanism is identical; only the destination differs.
  • FRAMEWORK-SPECIFICWhere the data object is an ORM entity with lazy loading, change tracking and a session lifetime, moving behaviour onto it means domain rules now execute inside the persistence framework's object graph — a method that touches a lazily-loaded relation can issue queries in a loop. In that setting the correct destination is usually a separate domain type, which makes the same refactoring a much larger piece of work (N+1 as a Design Problem).
  • CONTESTEDThe strongest opposing view is that rich domain objects are a liability at scale: behaviour on entities makes it hard to see what a request actually does, encourages implicit database access from anywhere in the object graph, and produces objects that are impossible to construct in a test. Teams that have hit that wall favour plain data plus explicit functions, and they can point at real systems that work well that way. The distinction that survives is whether the logic needs anything beyond the object's own data — pure, self-contained questions are safe to move under either style, and everything else is where the argument actually lives.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — where behaviour can live relative to data is partly a language decision: traits, extension methods and free functions in a module all change what "moving the responsibility" physically means.