Hide the Guts: Objects vs. Data Structures
Stop groping through other modules' fields — move the behavior to where the data lives, and learn where a dumb struct is exactly right.
The idea
We are taught that private fields plus getters equals encapsulation. It doesn't. A car that exposes getFuelTankCapacity() and getGallonsOfGasoline() has told every caller how it stores fuel, and every caller now does its own arithmetic. Real hiding is getPercentFuelRemaining() — an answer, not the raw ingredients.
Objects and data structures are exact opposites. An object hides its data and exposes behavior: you tell it to do things. A data structure exposes its data and has no behavior: functions elsewhere operate on it. Neither is better. With objects, adding a new kind of thing is easy and adding a new operation is painful. With data structures it flips. The useful question is what this code will grow — more kinds, or more operations.
That brings us to train wrecks. A line like order.getCustomer().getAddress().getCity() is four couplings pretending to be one line, and a change to any of them derails every chain like it. The fix is rarely another delegating getter; it is asking why you wanted the city at all, because there is usually a behavior that belongs inside the object holding the data. Note the nuance: this applies to objects. Chaining through a bare data structure, like config.server.port, is fine — there is no implementation to hide.
The worst shape is the hybrid: public-ish fields and business methods together. Callers can't tell whether to tell it things or reach into it, so they do both, and you get neither benefit. The cure is a clean split — a dumb data carrier plus a behavior class that owns the rules. Dumb data still has honorable jobs at boundaries: parsed wire messages, DB rows, config files. Keep those records dumb and put the rules somewhere else.
The bench — 4 exercises
Train-Wreck Derailment
Take an order and shipping flow riddled with three- and four-hop getter chains and dissolve them instead of shortening them. You'll feel how often a chain is a misplaced method.
- Grep your codebase (or the module's src directory) for member-access chains two or more hops deep and list every file and line.
- For each chain, write down in one sentence what the caller actually wanted — not the field, the decision.
- Move that computation into the object that owns the data, so the call site becomes a single tell, like order.isLocalDelivery().
- Collapse duplicated chain logic appearing at several call sites into one method, then delete every getter that now has no callers.
- Leave alone any chain that walks through a pure data structure, and note why it was safe.
hint
If your fix is order.getCustomerCity(), you relocated the wreck rather than derailing it — look at what the caller does with the city.
hint
Method chaining on the same receiver (fluent builders returning this) is not a train wreck; the hops must be into different types.
hint
Deleting the now-unused getters is the point: fields exposed only for the wrecks go back into hiding.
DONE WHEN
· All existing tests still pass, unchanged.
· No call site reaches more than one hop into a behavior-bearing object.
· Zero getters remain with no callers.
· The duplicated logic exists in exactly one place.
The Anti-Symmetry Lab
Implement the same tiny shape-geometry feature twice — procedural structs plus a switching area function, and one class per shape — then make two changes to both and watch the pain swap sides.
- Build both versions of Square and Circle with area(), passing one identical shared test suite.
- Round one: add a Triangle to both sides and record which files and existing functions you had to touch.
- Round two: add perimeter() to both sides and record the same footprint.
- Compare the two diffs side by side using git diff --stat between rounds.
- Write a short DECISION.md choosing a style for (a) a rendering pipeline gaining a new export format monthly and (b) a plugin system where users contribute node types.
hint
There is no wrong answer in DECISION.md — the point is deciding on the axis of change, not on vibes.
hint
(a) grows operations; (b) grows kinds. Say which side of the anti-symmetry that puts you on.
DONE WHEN
· The shared test suite passes on both implementations after both rounds.
· In the OO version, adding Triangle touched zero existing shape classes.
· In the procedural version, adding perimeter() touched zero existing struct definitions.
· DECISION.md has a written call for both scenarios.
De-Hybridize
Split a class that is half struct and half object — public fields mutated from outside plus business methods that read them — into a dumb record and a behavior class.
- Pick or write a Shipment class with public weight, zone and service-level fields plus quotePrice() and isExpressEligible(), mutated from several external call sites.
- Notice that a couple of call sites have grown their own bootleg pricing logic, and write down what each one computes.
- Extract a plain ShipmentRecord carrying only the data, with no methods.
- Create a Shipment behavior class that hides a record and exposes only decisions: quotePrice(), isExpressEligible(), applySurcharge().
- Fold the bootleg logic back into the behavior class so external code either holds the record or talks to the object, never both.
hint
The bootleg pricing isn't an accident to tidy up — it's evidence that hybrids train callers to reach in.
hint
Your split should make the honest path, calling the method, the only path.
hint
Rule of thumb: no class should both expose most of its state and define non-accessor methods.
DONE WHEN
· Existing behavior tests pass unchanged.
· No class in the module both exposes its fields and defines business methods.
· Every field read used for a pricing or eligibility decision happens inside the behavior class.
Keep the Record Dumb
Evict business rules from an Active Record so the domain object never knows the database exists — and prove it by testing against an in-memory fake.
- Write or take a CustomerRecord with find/save over a local SQLite file, into which isVip() and applyLoyaltyDiscount() have crept.
- Move those rules into a Customer domain object that takes the record as plain data.
- Strip the record back to fields, constructor and persistence methods only.
- Add an in-memory fake record source and run the domain object's tests against it.
hint
Unsure whether a method is business logic? Ask whether it would survive a database swap unchanged.
hint
If the fake is hard to write, the domain object still knows too much about storage.
DONE WHEN
· The real SQLite-backed tests still pass.
· CustomerRecord contains no business predicates or rule mutations.
· The domain-object tests pass against the fake with no database driver imported in the test files.
Go deeper (after the bench)
Read Clean Code Chapter 6, Objects and Data Structures (pp. 93-101) now — the fuel-tank getters and the Geometry-versus-polymorphic-shapes example are the direct ancestors of exercises 1 and 2, and the anti-symmetry pages land differently once you have felt one side hurt. For one free follow-up, Martin Fowler's TellDontAsk bliki entry is a short, balanced take on when the rule helps and when it gets over-applied.