Preserve Whole Object: Show the Whole ID Card
Learn the Preserve Whole Object refactoring with a school ID card story, TypeScript and C# examples, safe step-by-step mechanics, and an honest look at the coupling cost of passing whole objects.
🪪 The Story of the ID Card and the Slip
Meet Meera, a class six student in Kochi. Her school library has a strict-looking librarian, Miss Rosamma, and a rule: to borrow a book, the librarian must verify who you are.
On Meera's first visit, the senior at the door tells her the "procedure". Take a paper slip. Copy your roll number from your ID card onto the slip. Copy your name. Copy your class and section. Copy the card's expiry date. Then hand the slip to Miss Rosamma.
Meera does it. She copies R-2031, then Meera Nair, then 6-B, then MAR-2027, standing at the counter while the queue grows behind her. Miss Rosamma reads the slip, checks each item, and issues the book. The whole time, Meera's laminated ID card — which carries every single one of those fields, printed by the school office itself — hangs from her neck on a blue lanyard, untouched.
Next week, a new rule arrives: the librarian must also check the blood group printed on the card (for the trekking-club books, do not ask why). Now every student's slip is wrong. Everyone must add one more line. The seniors groan and update the "procedure" notice. The queue grows longer. And one boy, Anand from 6-C, copies his roll number wrongly — the slip says R-2013, his card says R-2031 — and gets a book issued against the wrong student. Chaos. The wrong Meera gets a fine notice two months later.
Then one day Miss Rosamma looks over her glasses at the whole queue and says the obvious thing: "Children. Why are you copying your card onto a slip? JUST SHOW ME THE CARD."
Of course! The ID card already carries the roll number, name, class, expiry, blood group — together, printed, laminated, impossible to mis-copy. When the rules change and one more field must be checked, nothing changes for the students: they were always handing over the whole card; only the librarian reads one extra line. The slip was pure extra work, a fresh chance for copying mistakes, and a thing that broke every time the rules grew.
The conversation at the counter literally shrinks from five exchanges to one:
Code queues up at the same counter. A caller takes a student object, pulls out student.rollNo, student.name, student.className, and passes them one by one to a method — manufacturing a slip. When the method later needs a fourth field, the signature changes and every caller must be edited. The refactoring that ends this is called Preserve Whole Object: stop dismantling the object; pass the card, not the slip.
🤔 What is Preserve Whole Object?
Preserve Whole Object is the refactoring where you replace a group of parameters that were all extracted from one object with a single parameter: the object itself. The method then asks the object for whatever fields it needs, inside its own body.
The telltale shape: just before a call, the caller does a little unpacking ritual —
const low = range.low;
const high = range.high;
if (room.withinRange(low, high)) { ... }— and the called method consumes exactly those pieces. The object was standing right there, whole and willing, and we shredded it at the doorstep.
This refactoring appears in both editions of Martin Fowler's Refactoring and keeps its name in the second edition (2018); the catalog at refactoring.com and Refactoring Guru both file it under techniques that simplify method calls and shorten parameter lists. Fowler's classic example is a room-temperature check: instead of extracting a day's low and high and passing two numbers, pass the temperature range object itself.
Think about what the slip actually contained. Meera's ID card has perhaps eight printed fields. The slip duplicated half of them — by hand, in a queue, with a leaky pen:
What do we gain by showing the card?
- Shorter signatures. Three, four, five extracted parameters become one. This attacks Long Parameter List head-on.
- Future-proof calls. When the method later needs one more field — the blood-group moment — only the method's body changes. Zero callers are touched. With the slip approach, every caller changes.
- No mis-copies. The caller cannot pass the fields in the wrong order (
withinRange(high, low)— a classic silent bug) because there are no loose fields anymore. - Deleted boilerplate. The unpacking ritual at every call site simply disappears.
- Design X-ray. Once the method holds the whole object, you often notice it uses only that object's data — the Feature Envy smell — which invites the follow-up Move Method: maybe
withinRangebelongs on the range.
One line to remember: if several arguments all came off one object, the object IS the argument. Show the card; let the reader read. The method that receives the whole object can always take less from it — but a method that receives shredded fields can never take more without breaking every caller.
One honest caution before we go on: passing the whole object couples the method to that object's type. A method that took two numbers worked with any two numbers from anywhere; a method that takes a TempRange now works only with TempRanges. Inside one module, this trade is almost always worth it. Across module or service boundaries, it may not be. We will weigh this properly in the Benefits and risks section — it is the most important fine print of this refactoring.
College corner: classic software-engineering texts (Yourdon and Constantine's structured design, later Meilir Page-Jones) give names to exactly this trade. Passing individual primitive values is data coupling — the loosest, healthiest form. Passing a whole structure when the callee uses only part of it is stamp coupling — slightly tighter, because the callee's signature now depends on a composite type and silently advertises access to fields it never reads. Preserve Whole Object deliberately moves you from data coupling toward stamp coupling, and the refactoring is justified when the change-resilience gain (new fields without signature churn) outweighs the dependency cost. That calculation flips at module boundaries: a public API that stamp-couples to your rich internal Student type exports your whole domain model as contract. This is why the narrow-interface middle path matters so much — it buys back most of the looseness while keeping the single-argument convenience.
🚦 When do we need it?
Look for these triggers:
- The unpacking ritual. Two or more lines before a call exist only to pull fields out of one object, and those locals are used only as arguments. That is a slip being written.
- Long Parameter List. Signatures like
register(name, rollNo, className, section, bloodGroup)where every argument has the same origin object. Fowler's general guidance on long lists starts exactly here: if the values come from one object, pass the object. - Data Clumps. The same little gang of fields —
low, high;street, city, pincode;name, rollNo, className— travels together through many signatures. If they already live in one object, Preserve Whole Object collapses the clump everywhere. If they do not yet live in one object, the sibling refactoring Introduce Parameter Object creates one first; Preserve Whole Object is for when the whole already exists. - Signature churn. The method's parameter list has grown twice this year, and each growth rippled through every caller. That ripple is the slip-rewriting queue at the library.
- Wrong-order bugs. Any past bug where two same-typed arguments were swapped (
withinRange(high, low)) is evidence the loose fields are dangerous.
And when the answer is no:
- The values do not come from one object. If the caller computes
lowfrom one source andhighfrom another, there is no card to show. Do not glue an artificial object together just to follow a rule. - The method should not know that type. A general utility like
clamp(value, min, max)serves callers from everywhere; chaining it toTempRangewould narrow a deliberately wide tool. - Across a boundary you want to keep thin. If a payments module's function takes a rich
Studentobject just to read one email field, the module now depends on the whole Student shape forever. Primitives, or a tiny dedicated type, can be the looser and more stable contract there. - Only one trivial field is used. Passing a whole object so the method can read one string gains little and couples a lot. Judgement, not dogma.
Here is the choice as a flow — note that "no" answers route you to the sibling refactorings rather than to a dead end:
👀 Before and after at a glance
The library counter in TypeScript. The before code writes slips:
interface Student {
rollNo: string;
name: string;
className: string;
cardExpiry: Date;
bloodGroup: string;
}
class Library {
// BEFORE: the method receives a slip of copied fields
canBorrow(rollNo: string, className: string, cardExpiry: Date): boolean {
if (cardExpiry < this.today()) return false;
if (this.finesOwedBy(rollNo) > 0) return false;
return this.allowedClasses.includes(className);
}
}
// every caller performs the copying ritual:
const rollNo = student.rollNo;
const className = student.className;
const cardExpiry = student.cardExpiry;
if (library.canBorrow(rollNo, className, cardExpiry)) {
issueBook(student, book);
}Now the trekking-club rule lands: canBorrow must also check bloodGroup is on record. With slips, the signature gains a fourth parameter and every caller in the codebase must be edited to copy one more field. Instead — show the card:
class Library {
// AFTER: the method receives the whole card and reads what it needs
canBorrow(student: Student): boolean {
if (student.cardExpiry < this.today()) return false;
if (this.finesOwedBy(student.rollNo) > 0) return false;
if (!this.allowedClasses.includes(student.className)) return false;
return student.bloodGroup !== ""; // new rule: body-only change!
}
}
// callers shrink to one honest line:
if (library.canBorrow(student)) {
issueBook(student, book);
}The new rule changed one method body. No caller noticed. No slip was rewritten. And the wrong-order bug (canBorrow(className, rollNo, ...) — both strings, compiler silent) became unwritable.
In class-diagram terms, three loose strings and a date are replaced by one association to the type that always owned them:
🪜 Step-by-step, the safe way
The trick to doing this without breaking anything is a short period where the method accepts both the whole object and the old loose parameters. The codebase moves through these states:
Climb like this:
- Confirm the single origin. Check every caller: do all the loose arguments truly come from one object the caller already holds? If even one caller computes a value from elsewhere, mark it — it may need an adapter or may be a sign to stop.
- Add the whole-object parameter alongside the old ones. Nothing uses it yet; everything still compiles.
// Intermediate state 1: both the card AND the slip are accepted
canBorrow(student: Student, rollNo: string, className: string, cardExpiry: Date): boolean {
if (cardExpiry < this.today()) return false;
if (this.finesOwedBy(rollNo) > 0) return false;
return this.allowedClasses.includes(className);
}
// callers updated mechanically: library.canBorrow(student, rollNo, className, cardExpiry)- Inside the body, replace one loose parameter at a time with a read from the object. Run tests after each replacement — these are one-line, fully reversible steps.
// Intermediate state 2: body now reads from the object; old params are dead weight
canBorrow(student: Student, rollNo: string, className: string, cardExpiry: Date): boolean {
if (student.cardExpiry < this.today()) return false;
if (this.finesOwedBy(student.rollNo) > 0) return false;
return this.allowedClasses.includes(student.className);
}- Delete the now-unused parameters from the signature, and at each caller delete both the argument and its extraction line. The compiler (or a linter flagging unused variables) walks you to every site.
- Run the full suite. Behaviour must be identical — the same fields are read, just one step later.
- Look for the follow-up. Does
canBorrownow use mostlystudent's data and little ofLibrary's own? That is Feature Envy showing itself; consider Move Method. Also check whether the method truly needs the whole student or only a stable slice — anIdCardinterface with just the fields read can keep the coupling narrow.
Two traps. First, order bugs hide in the old code: while migrating, you may discover one caller was already passing high, low swapped — congratulations, you found a real bug, but fix it as a separate, clearly labelled change, not silently inside the refactoring. Second, do not let the method start mutating the object. With loose copies, the method could not damage the original; with the whole object in hand, a careless student.cardExpiry = ... becomes possible. Prefer read-only types (TypeScript Readonly<Student>, C# records or IReadOnly... interfaces) to keep the card laminated.
🌶️ A bigger real-life example
Meera's father sells spices online. His shop's code must answer: can we deliver this parcel to this address? The before version shreds the address at every doorstep:
interface Address {
street: string;
city: string;
state: string;
pincode: string;
isRemoteArea: boolean;
}
class DeliveryService {
// BEFORE: four shreds of one address
isServiceable(pincode: string, state: string, isRemoteArea: boolean, weightKg: number): boolean {
if (!this.servedStates.includes(state)) return false;
if (this.blockedPincodes.has(pincode)) return false;
if (isRemoteArea && weightKg > 10) return false;
return true;
}
estimateDays(pincode: string, isRemoteArea: boolean): number {
const zone = this.zoneOf(pincode);
return isRemoteArea ? zone.baseDays + 3 : zone.baseDays;
}
}
// caller — the shredding ritual, twice:
const pincode = order.address.pincode;
const state = order.address.state;
const remote = order.address.isRemoteArea;
if (service.isServiceable(pincode, state, remote, order.weightKg)) {
const days = service.estimateDays(pincode, remote);
showPromise(days);
}Note the smell stack: the same clump (pincode, state, isRemoteArea) rides through multiple signatures — a textbook Data Clumps — and each signature is creeping toward a Long Parameter List. After Preserve Whole Object:
class DeliveryService {
// AFTER: the address travels whole; weight stays separate — it is not part of the address
isServiceable(address: Address, weightKg: number): boolean {
if (!this.servedStates.includes(address.state)) return false;
if (this.blockedPincodes.has(address.pincode)) return false;
if (address.isRemoteArea && weightKg > 10) return false;
return true;
}
estimateDays(address: Address): number {
const zone = this.zoneOf(address.pincode);
return address.isRemoteArea ? zone.baseDays + 3 : zone.baseDays;
}
}
// caller:
if (service.isServiceable(order.address, order.weightKg)) {
showPromise(service.estimateDays(order.address));
}Two details deserve attention. First, weightKg stayed a separate parameter — it belongs to the parcel, not the address. Preserve Whole Object is not "stuff everything into one bag"; it is "stop shredding a bag that already exists". Second, when the courier partner later adds a rule about flood-affected districts (a new district field on Address), only the two method bodies change. The old design would have rippled a fifth parameter through every caller.
The shop's codebase had eleven call sites for isServiceable. Count the edits each design demands when the flood-district rule lands:
Twelve edits (eleven callers plus the signature) versus two method bodies. That gap grows with every new caller — which is exactly why slip-style signatures get more painful the more successful the codebase becomes.
💻 The same refactoring in C#
The same move, in a hostel-management system checking whether a room's temperature plan suits a day's weather:
// BEFORE
public class Room
{
public int Temperature { get; }
public bool WithinPlan(int low, int high)
=> Temperature >= low && Temperature <= high;
}
// caller dismantles the plan object first:
var low = heatingPlan.Low;
var high = heatingPlan.High;
if (!room.WithinPlan(low, high))
alerts.Raise($"Room {room.Id} outside heating plan");After passing the whole plan:
// AFTER
public record TempRange(int Low, int High)
{
public bool Includes(int value) => value >= Low && value <= High;
}
public class Room
{
public int Temperature { get; }
public bool WithinPlan(TempRange range) => range.Includes(Temperature);
}
// caller:
if (!room.WithinPlan(heatingPlan.Range))
alerts.Raise($"Room {room.Id} outside heating plan");Notice what happened beyond the parameter count: once TempRange arrived whole, the comparison logic moved onto the range itself as Includes — data and behaviour reunited. That is the Feature Envy follow-up happening naturally. Using a C# record also keeps the object immutable, so WithinPlan can read but never damage the plan — the laminated-card guarantee, enforced by the language.
College corner: the narrow-interface middle path is the Interface Segregation Principle applied to parameters. Instead of coupling announceEntry to the full Athlete type, you declare the smallest shape the method actually reads — in TypeScript, a structural type like an object with just name, house, and chestNo; in C#, a small interface the rich type implements. Now the method states its true dependencies in its signature (good for readers), accepts anything that satisfies the shape (good for reuse — teachers, guests, mascots), and remains immune to unrelated changes in the rich type (good for stability). TypeScript's structural typing makes this nearly free; nominal languages pay a little ceremony for the same insurance. The general rule: within a module, pass the rich object; at the boundary, pass the narrowest honest shape.
🛠️ IDE support
No mainstream IDE offers a single "Preserve Whole Object" action — the step of reading fields off the new parameter needs human eyes. But the mechanics are well supported:
- IntelliJ IDEA / Rider / WebStorm — Change Signature (Ctrl+F6) adds the whole-object parameter and updates every call site with a default argument you specify (e.g.,
student); later it removes the dead loose parameters the same way. Find Usages locates every caller's extraction ritual for deletion, and inspections flag the unused parameters for you. - ReSharper / Visual Studio (C#) — Change Signature plus the "parameter is never used" inspection drive the same ladder; ReSharper's Transform Parameters refactoring can even bundle loose parameters into a new class, which is the sibling move (Introduce Parameter Object) for when no whole object exists yet.
- IntelliJ's Extract Parameter Object — likewise automates the sibling; useful when your "card" must first be created.
- TypeScript in VS Code — no signature-wide automation, but the compiler is your migration engine: add the new parameter, remove the old ones, and follow the red squiggles caller by caller.
The practical recipe everywhere: Change Signature to add the object → swap body reads manually (tests between swaps) → Change Signature again to drop the dead parameters → let unused-variable warnings clean the call sites.
⚖️ Benefits and risks
| Aspect | Benefit | Risk / Cost |
|---|---|---|
| Signature length | 3–5 extracted params collapse to 1; directly cures Long Parameter List | — |
| Future changes | New field needed → body-only change; zero caller edits | — |
| Correctness | Wrong-order argument bugs (two strings swapped) become unwritable | A latent swap bug in old callers may surface mid-refactor — fix it separately and visibly |
| Call-site noise | The unpacking ritual disappears everywhere | — |
| Coupling | — | The honest cost: the method now depends on the object's type. A function that took two ints worked with any two ints; it now works only with TempRange. Reuse narrows, and tests must construct the whole object |
| Encapsulation | — | The method can now see (and, if the type is mutable, modify) far more than the two fields it needs — a wider surface for misuse; prefer read-only types or a narrow interface |
| Boundaries | Within a module: usually a clear win | Across module/service boundaries, a rich domain type makes a heavy contract; primitives or a small DTO can be the looser, more stable choice |
| Design insight | Exposes Feature Envy → often leads to a better home for the method (Move Method) | — |
A fair way to hold this in your head: Preserve Whole Object trades caller convenience and change-resilience for type coupling. Inside one codebase region that already knows the type, the trade is excellent. At a published API edge, think twice — and consider a middle path: accept a narrow interface (just { low, high } or an IdCard with three fields) so the method is coupled to a small stable shape, not to the entire rich object.
The three shapes a signature can take, compared side by side:
| Signature style | Example | Coupling | New-field cost | Best home |
|---|---|---|---|---|
| Loose primitives (the slip) | canBorrow(rollNo, className, expiry) | Loosest — any values from anywhere | Every caller edited | Tiny utilities, far boundaries |
| Whole object (the card) | canBorrow(student) | Tightest — full rich type | Body-only change | Inside one module that owns the type |
| Narrow interface (the window in the card cover) | canBorrow(card: IdCard) | Small stable slice | Body-only, if field is on the slice | Module edges, shared helpers |
You can place any candidate call on a two-axis map. The horizontal axis asks how many of the object's fields the method needs; the vertical axis asks how far the call crosses module boundaries:
Read the corners: canBorrow needs many fields and lives beside Student — pass the card. The payments module needs one email across a far boundary — send the primitive. The transport module's announcement sits midway — a narrow three-field interface is the graceful answer.
🩺 Which smells does it cure?
| Smell | How Preserve Whole Object helps |
|---|---|
| Long Parameter List | The primary target — several same-origin parameters become one |
| Data Clumps | The travelling gang of fields is replaced by its existing home object across all signatures |
| Feature Envy | Not cured but revealed — once the object arrives whole, a method that mostly reads it stands exposed, inviting Move Method |
| Primitive Obsession | Loose primitives at call sites give way to a meaningful domain type |
| Shotgun Surgery | Adding a field to a check no longer ripples through every caller |
🧠 The whole idea in one mindmap
📝 Quick revision box
+=============== PRESERVE WHOLE OBJECT ================+
| |
| SMELL : caller copies fields off one object |
| (the slip) and passes them one by one |
| canBorrow(rollNo, className, expiry) |
| |
| MOVE : pass the object itself (the ID card) |
| canBorrow(student) |
| |
| LADDER: 1 confirm single origin 2 add object |
| param BESIDE old ones 3 swap body reads |
| one at a time 4 drop dead params |
| 5 delete extraction lines at callers |
| |
| WIN : new field needed -> body-only change |
| COST : method is now COUPLED to the object type; |
| across boundaries prefer a narrow shape |
| |
| NEXT : body full of student.x? Feature Envy -> |
| consider Move Method |
+======================================================+🏋️ Practice exercise
A sports-day registration system shreds athletes at every counter — refactor it:
interface Athlete {
chestNo: string;
name: string;
ageGroup: "U12" | "U14" | "U16";
house: string;
hasMedicalClearance: boolean;
}
class EventDesk {
canRegister(ageGroup: string, hasMedicalClearance: boolean, chestNo: string): boolean {
if (!this.eventAgeGroups.includes(ageGroup)) return false;
if (!hasMedicalClearance) return false;
return !this.alreadyRegistered.has(chestNo);
}
announceEntry(name: string, house: string, chestNo: string): string {
return `${name} (${chestNo}) of ${house} house, please report to the track.`;
}
}
// caller:
const ageGroup = athlete.ageGroup;
const clearance = athlete.hasMedicalClearance;
const chestNo = athlete.chestNo;
if (desk.canRegister(ageGroup, clearance, chestNo)) {
speaker.say(desk.announceEntry(athlete.name, athlete.house, athlete.chestNo));
}Your tasks:
- Confirm every parameter of both methods originates from the one
Athleteobject. - Apply the both-parameters bridge: add
athlete: Athletebeside the old parameters ofcanRegister, swap the body reads one at a time, then drop the dead parameters. Repeat forannounceEntry. - Delete the extraction ritual at the caller and watch it shrink to two lines.
- New rule from the sports teacher: U12 athletes may register only if their
househas fewer than 10 entries. Show that this becomes a body-only change in your refactored version — and write down how many files it would have touched in the before version. - Coupling question: the school's transport module also wants
announceEntry-style strings, but for teachers, who are notAthletes. ShouldannounceEntrytake the wholeAthlete? Design a narrow interface (perhaps{ name, house, chestNo }) and explain in two sentences why a thin shape is the better contract at that boundary. - Stretch: after refactoring,
canRegisterreads three fields ofathleteand only one thing of its own (alreadyRegistered). Which smell is showing, and which follow-up refactoring would you consider? (Hint: re-read the Feature Envy paragraphs.) - Chart it: place
canRegister,announceEntry, and the transport module's request on the quadrant chart from Figure 10. Which one earns the whole object, which one earns the narrow interface, and why?
Frequently asked questions
- What does Preserve Whole Object mean in one sentence?
- When a caller pulls several values out of one object just to pass them as separate arguments, pass the whole object instead and let the method ask it for what it needs — like showing your whole ID card to the librarian instead of copying roll number, name, and class onto a slip.
- How is this different from Introduce Parameter Object?
- Preserve Whole Object applies when the values ALREADY live together in one object — you simply stop dismantling it. Introduce Parameter Object applies when the values are loose and no such object exists yet — you create one first. Same destination (shorter signatures), different starting point.
- Doesn't passing the whole object create coupling?
- Yes, honestly, it does. A method that took two numbers worked with ANY two numbers; a method that takes a Student object now works only with Students, and it can see everything the object exposes. Inside one module this trade is usually worth it. Across module boundaries, primitives or a small dedicated type are often the looser, more stable contract.
- What if the method needs values from two different objects?
- Then there is no single whole object to pass, and you should not glue one together artificially. Pass the two objects, or pass the individual values, or step back and ask whether the method belongs somewhere else. Preserve Whole Object only applies when the arguments genuinely originate from one cohesive object.
- What is the connection with Feature Envy?
- After you pass the whole object, the method's body becomes a row of calls on that object — range.low, range.high, range.includes. That visual is a strong hint the method is more interested in the other object than in its own class, the Feature Envy smell. The natural follow-up is Move Method: relocate the logic onto the object whose data it loves.
Further reading
Related Lessons
Replace Parameter with Method Call: Don't Tell the Shopkeeper His Own Prices
Learn the Replace Parameter with Method Call refactoring (Replace Parameter with Query in Fowler's 2nd edition) with a kirana shop story, TypeScript and C# examples, safe mechanics, and the testability fine print.
Parameterize Method: One Juice Recipe with a Size Input
Learn the Parameterize Method refactoring with a juice stall story, TypeScript and C# examples, safe step-by-step mechanics, and the seesaw rule that pairs it with Replace Parameter with Explicit Methods.
Replace Parameter with Explicit Methods: Name Boards Instead of Secret Codes
Learn the Replace Parameter with Explicit Methods refactoring with a bank counter story, TypeScript and Python examples, safe mechanics, and the seesaw rule that pairs it with Parameterize Method.
Long Parameter List: The Chai Order That Took Ten Instructions
Long Parameter List code smell made simple — why methods with too many arguments cause bugs, and how parameter objects make calls short, clear, and safe.