Replace Data Value with Object: Give Your Data a Proper Home
Replace Data Value with Object explained simply — how to grow a plain string or number into a small class with validation and behaviour, with TypeScript and C# record examples.
📓 The Number on the Back of a Notebook
Ravi runs a small mobile-recharge shop in Nagpur, squeezed between a tailor and a tea stall. Business is good, but Ravi's "customer database" is the back cover of an old maths notebook. When customers want a recharge later, he scribbles their numbers in pencil: "Sunita 98230...", "Verma ji 9822 something", "Big moustache uncle 982305467".
See the trouble? One number has only nine digits — a recharge to it will simply fail, and Ravi will only find out when the angry customer returns. One has a gap in it — was that a space or a missing digit? And "big moustache uncle" — which of the three Vermas with moustaches is that? Every time Ravi dials, he must squint at the pencil marks, count digits on his fingers, and hope.
One Tuesday it goes wrong in the worst way. Ravi recharges ₹399 onto a wrong number — a stranger in Bhopal gets a free month of data, and Sunita gets nothing. Ravi loses the money and the argument.
His daughter Priya, home from her engineering college for the holidays, watches this happen and quietly takes his phone. She sets up a proper contact card system. Now every contact has a name, a 10-digit number that the phone refuses to save if a digit is missing, and a neat display like +91 98230 54678. When Ravi tries to save "982305467" for Sunita, the phone immediately complains: one digit short. The mistake is caught at the door, not three days later in Bhopal.
And here is the part Priya explains over dinner: the checking happens once, at saving time. After that, every part of the phone — the dialer, WhatsApp, SMS — simply trusts the contact card. No one counts digits ever again. The dialer does not re-check. WhatsApp does not re-check. They cannot even receive a bad number, because a bad number cannot become a contact card in the first place.
That is the whole refactoring in one story. A phone number scribbled as plain digits is a data value — naked, unchecked, its meaning known only in Ravi's head. A contact card is an object — it carries its own rules and its own behaviour wherever it goes.
Replace Data Value with Object says: when a plain string or number starts collecting rules and behaviour, stop scribbling. Make a contact card.
🧠 What is Replace Data Value with Object?
Replace Data Value with Object is a refactoring from Martin Fowler's Refactoring book (the 2nd edition renames it Replace Primitive with Object — same idea). The move: take a field that is a bare primitive — string, number, decimal — and promote it into a small class of its own. The class holds the raw value inside, validates it at creation, and offers the operations that belong to it.
Why does this matter so much? Because data attracts behaviour. A field rarely stays simple:
- The string phone number needs a 10-digit check. Then a formatting rule. Then a masking rule for receipts (
98XXXXX678). - The number called
priceneeds rounding rules. Then a currency. Then a rule that it can never be negative.
If the value stays a primitive, where does all this logic live? It gets smeared across every class that touches the value — Order validates the phone, Invoice validates it again slightly differently, SmsService formats it a third way. The logic that belongs to the phone number is squatting in other people's houses, duplicated and slowly drifting apart. One day one copy allows 11 digits, another copy allows spaces, and the recharge goes to Bhopal again.
The refactoring gives that logic its one true home. And the class you create is usually a value object — a small, immutable type compared by its contents. Two contact cards with the same number are interchangeable, just like two ₹10 notes. This is the key question of value thinking, and we will meet it again in the next two refactorings:
"Is it THE same thing, or just AN equal thing?"
For a phone number, "an equal thing" is enough — any object holding 9823054678 is as good as any other. That is value equality. (Compare with a customer, where it matters which customer — that is identity. More on this in Change Value to Reference.)
Fowler calls this one of the most valuable refactorings in the book, and his favourite example of a value object is Money — an amount plus a currency, equal by contents, immutable. If you remember only one slogan, remember this: once a value has rules, the rules deserve a roof. The roof is a small class.
College corner: the formal name for "a bad number cannot become a contact card" is making illegal states unrepresentable. With a primitive, the type string admits infinitely many invalid phone numbers, so every consumer must defend itself — and defensive checks scattered across consumers are exactly the duplication this refactoring removes. With a value object, the constructor is the only door, and it is guarded; therefore the mere existence of a PhoneNumber instance is a proof — in the type-system sense — that validation already passed. Type theorists call this "parse, don't validate": convert untrusted input into a richer type once, at the boundary, then let the type carry the guarantee everywhere.
🔔 When do we need it?
This refactoring is the direct cure for Primitive Obsession — the smell of using raw strings and numbers for rich domain ideas. Reach for it when you notice:
- Repeated validation. The same "is this a valid phone number?" check appears in three files. Each copy can drift. One day one copy allows 11 digits and the bug hunt begins.
- Behaviour about the value living in the wrong class.
Order.customerInitials(),Invoice.maskPhone(),Report.formatAmount()— host classes doing the value's thinking. - Primitive parameters that get mixed up.
sendSms(phone: string, message: string)— pass them in the wrong order and the compiler shrugs.sendSms(phone: PhoneNumber, message: string)makes the mistake impossible. - A value with a unit or format — money, percentages, distances, dates as strings, PIN codes. A bare
500does not know if it is rupees or paise. (Ask NASA: the Mars Climate Orbiter was lost because one team's software produced pound-seconds while another expected newton-seconds — a 327-million-dollar primitive-obsession bug.) - A few primitives that always travel together — street, city, PIN — which is the Data Clumps smell. Bundling a clump into one object is this refactoring's twin (often called Introduce Parameter Object when applied to parameters).
When should you not do it? When the value has no rules and no behaviour. A loop index, a temporary count, an internal flag — wrapping these adds a class and gains nothing. Also remember the boundary cost: the new type must be mapped for JSON and the database. The value should earn that cost with the bugs it prevents.
A quick sorting table — which values have earned a class?
| Candidate | Has rules? | Has behaviour? | Verdict |
|---|---|---|---|
| Phone number | ✅ 10 digits, starts 6-9 | ✅ format, mask | Promote it |
| Money amount | ✅ no negatives, paise rounding | ✅ add, percent, display | Promote it |
| Email address | ✅ shape check | ✅ domain part, normalize | Promote it |
Loop counter i | ❌ | ❌ | Leave it as a number |
Boolean flag isOpen | ❌ | ❌ | Leave it alone |
| PIN code | ✅ 6 digits | ✅ city lookup | Promote it |
👀 Before and after at a glance
Ravi's shop, in TypeScript. Before — the phone number is a naked string, and Customer does the phone's thinking:
// BEFORE — a scribble in a notebook
class Customer {
constructor(
public name: string,
public phone: string, // any string at all!
) {}
isPhoneValid(): boolean {
return /^[6-9]\d{9}$/.test(this.phone); // phone logic inside Customer
}
maskedPhone(): string {
return this.phone.slice(0, 2) + "XXXXX" + this.phone.slice(7); // and again
}
}
const c = new Customer("Sunita", "982305467"); // 9 digits — accepted silently!After — the phone number becomes a contact card that checks itself:
// AFTER — a proper contact card
class PhoneNumber {
private readonly digits: string;
constructor(raw: string) {
const cleaned = raw.replace(/[\s-]/g, "");
if (!/^[6-9]\d{9}$/.test(cleaned)) {
throw new Error(`Invalid Indian mobile number: ${raw}`);
}
this.digits = cleaned;
}
toString(): string {
return `+91 ${this.digits.slice(0, 5)} ${this.digits.slice(5)}`;
}
masked(): string {
return this.digits.slice(0, 2) + "XXXXX" + this.digits.slice(7);
}
equals(other: PhoneNumber): boolean {
return this.digits === other.digits; // value equality — AN equal thing
}
}
class Customer {
constructor(
public readonly name: string,
public readonly phone: PhoneNumber,
) {}
}
const phone = new PhoneNumber("982305467"); // throws AT ONCE — bug caught at the doorNotice three improvements. The nine-digit bug now explodes at creation, not at recharge time. The masking and formatting logic moved into the type that owns it. And Customer shrank — it no longer does anyone else's thinking.
🪜 Step-by-step, the safe way
Small steps, tests green after each one. Here is the order Fowler recommends, adapted gently.
-
Self-encapsulate the field first. If many methods touch
this.phonedirectly, route them through a getter and setter (see Self Encapsulate Field). Now the coming change touches one place. -
Create the new class with just the raw value inside. No behaviour yet. Give it a constructor that stores the primitive, and a getter that returns it.
class PhoneNumber {
constructor(private readonly raw: string) {}
get value(): string {
return this.raw;
}
}-
Change the host's field type to the new class. Update the host's constructor and setter to wrap the incoming primitive:
this.phone = new PhoneNumber(raw). Keep the host's getter returning the primitive for now (return this.phone.value), so no caller changes yet. Compile, test. -
Add validation to the new class's constructor. Now invalid values die at the door. Run the tests — if any test fails here, congratulations, you just discovered places that were creating invalid data all along.
-
Move behaviour, one method at a time. Take
maskedPhone()fromCustomer, recreate it asmasked()onPhoneNumber, make the old method delegate, test, then inline the old method away. Repeat for each piece of phone logic anywhere in the codebase. -
Upgrade callers to use the object. Gradually change signatures from
phone: stringtophone: PhoneNumber, letting the compiler point at every spot that needs attention. Finally, decide on equality and immutability — usuallyreadonlyfields and anequalsmethod, making it a true value object.
The riskiest moment is step 4. Adding validation is technically a behaviour change — data that used to slip through now throws. Search your database and logs for existing invalid values BEFORE deploying. Many teams add a temporary "lenient mode" that logs instead of throwing for a week, fix the dirty data, and then switch the constructor to strict.
🏫 A bigger real-life example
A school fee system in Jaipur stores fees as bare numbers. The accountant, Mr. Saxena, has been bitten twice: once when late-fee paise rounding went wrong and three hundred receipts were off by one paisa each (the auditor was not amused), and once when a careless refund made a fee negative and a student's parent received a bill saying the school owed them money. Money is begging for a class:
// BEFORE — money as a naked number
class FeeAccount {
private balance = 0; // rupees? paise? who knows
charge(amount: number): void {
this.balance = this.balance + amount;
}
payOnline(amount: number): void {
const fee = amount * 0.02; // gateway fee, float maths — 0.1 + 0.2 problems!
this.balance = this.balance - amount + fee;
}
}After promoting money to a value object that stores paise as integers (no float surprises) and refuses nonsense:
// AFTER — Money owns its rules
class Money {
private constructor(private readonly paise: number) {
if (!Number.isInteger(paise)) throw new Error("Paise must be whole");
if (paise < 0) throw new Error("Money cannot be negative");
}
static fromRupees(rupees: number): Money {
return new Money(Math.round(rupees * 100));
}
add(other: Money): Money {
return new Money(this.paise + other.paise); // returns NEW value
}
subtract(other: Money): Money {
return new Money(this.paise - other.paise); // negative? constructor catches it
}
percent(rate: number): Money {
return new Money(Math.round((this.paise * rate) / 100));
}
equals(other: Money): boolean {
return this.paise === other.paise;
}
toString(): string {
return `Rs. ${(this.paise / 100).toFixed(2)}`;
}
}
class FeeAccount {
private balance = Money.fromRupees(0);
charge(amount: Money): void {
this.balance = this.balance.add(amount);
}
payOnline(amount: Money): void {
const gatewayFee = amount.percent(2);
this.balance = this.balance.subtract(amount).add(gatewayFee);
}
}Every old bug now has a guard standing at the door. Float rounding? Gone — paise are integers. Negative balance from a careless refund? The constructor throws. Rupees-versus-paise confusion? The type says Money, and there is exactly one way in: fromRupees. And note the style: add and subtract return new Money objects instead of changing the old one. That immutability is what makes a value object safe to pass around freely — it is the contact card no one can scribble on.
College corner: why integers for paise instead of number rupees? Because IEEE-754 floating point cannot represent most decimal fractions exactly — 0.1 + 0.2 is famously 0.30000000000000004. For money, the standard professional answers are: store the smallest currency unit in an integer (as here), use a decimal type (decimal in C#, BigDecimal in Java, Decimal in Python), or use a dedicated money library. The deeper lesson: the representation choice is itself a rule about the value, and a value object is exactly the place where such a rule belongs — chosen once, enforced everywhere, invisible to callers.
💜 The same refactoring in C#
Here C# shows off, because records are tailor-made for value objects. A record gives you value equality, GetHashCode, immutability with init, a readable ToString, and non-destructive updates with with — all generated by the compiler:
public sealed record PhoneNumber
{
public string Digits { get; }
public PhoneNumber(string raw)
{
var cleaned = raw.Replace(" ", "").Replace("-", "");
if (!Regex.IsMatch(cleaned, @"^[6-9]\d{9}$"))
throw new ArgumentException($"Invalid Indian mobile number: {raw}");
Digits = cleaned;
}
public string Masked => $"{Digits[..2]}XXXXX{Digits[7..]}";
public override string ToString() => $"+91 {Digits[..5]} {Digits[5..]}";
}
// Value equality comes FREE with records:
var a = new PhoneNumber("98230 54678");
var b = new PhoneNumber("9823054678");
Console.WriteLine(a == b); // True — equal by contents
Console.WriteLine(a.Equals(b)); // TrueAnd Money as a compact readonly record struct — no heap allocation, full value semantics:
public readonly record struct Money(long Paise)
{
public static Money FromRupees(decimal rupees) =>
new(checked((long)Math.Round(rupees * 100)));
public Money Add(Money other) => new(Paise + other.Paise);
public Money Percent(decimal rate) =>
new((long)Math.Round(Paise * rate / 100));
public override string ToString() => $"Rs. {Paise / 100m:F2}";
}Compare this with the old way — hand-writing Equals, GetHashCode, and operator == and keeping them in sync with every field change. Records removed an entire category of boilerplate bugs. Microsoft's own DDD guidance for .NET recommends value objects exactly like these; with EF Core you map them using value converters or owned entity types, so the database sees a simple column while your code sees a rich type.
College corner: structural equality versus reference equality is the precise vocabulary here. A C# class defaults to reference equality — two objects are equal only if they are the same heap object. A record (and a struct) implements structural equality — equal if all fields are equal. Value objects demand structural equality, and crucially, GetHashCode must be derived from the same fields, because hash-based collections first bucket by hash and only then call Equals. If two structurally equal objects hash differently, a HashSet can hold both and a Dictionary lookup can miss — silently. This Equals-hashCode contract is the most common hand-rolled value-object bug, and it is the strongest argument for letting the compiler generate both.
A Python version, for completeness — @dataclass(frozen=True) is the records of Python land:
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
paise: int
def __post_init__(self) -> None:
if self.paise < 0:
raise ValueError("Money cannot be negative")
@staticmethod
def from_rupees(rupees: float) -> "Money":
return Money(round(rupees * 100))
def add(self, other: "Money") -> "Money":
return Money(self.paise + other.paise)
def __str__(self) -> str:
return f"Rs. {self.paise / 100:.2f}"
# frozen=True gives immutability; dataclass gives __eq__ by contents;
# eq=True with frozen=True also makes it hashable — dict-key safe.
assert Money.from_rupees(15) == Money.from_rupees(15) # AN equal thing🛠️ IDE support
There is no single "Replace Data Value with Object" button, because the refactoring is a recipe of smaller moves — and IDEs automate every ingredient:
| Tool | Helpful moves |
|---|---|
| Visual Studio / Rider (C#) | Extract Class and Encapsulate Field to start; Change Signature to swap string for PhoneNumber across all callers; quick-fix to convert a class to a record. |
| IntelliJ IDEA (Java) | Refactor → Extract Delegate moves a field plus its methods into a new class; Type Migration changes a field's type and chases the fallout. |
| VS Code (TypeScript) | Rename Symbol to safely rename the old field, Extract to function/constant for behaviour pieces; the compiler errors after a type change act as a precise to-do list. |
| All of them | Find Usages on the primitive field shows you the full blast radius before you begin. |
A practical TypeScript trick: change the field's type first and let the red squiggles guide you. The compiler becomes your checklist — every error is one caller still passing a raw string.
⚖️ Benefits and risks
| Benefits | Risks / Costs |
|---|---|
| Validation runs once, at creation — invalid values become unrepresentable. | A new type to write, test, and map at JSON/database boundaries. |
| Behaviour lives with its data; duplication across host classes disappears. | Over-wrapping trivial values adds noise without safety. |
The domain gains vocabulary: PhoneNumber, Money, PinCode instead of anonymous strings. | Existing dirty data may explode when strict validation arrives — migrate first. |
The compiler stops parameter mix-ups (Money cannot be passed where PhoneNumber is expected). | Slight allocation cost in very hot loops (C# readonly record struct solves this). |
Natural first step toward full value-object design — equality, immutability, with-updates. | Deciding sharing needs thought: if updates must be seen everywhere, you need Change Value to Reference next. |
Two pictures to weigh the trade honestly.
The quadrant chart already hints at the next two lessons: concepts in the bottom-left are entities and belong behind a shared reference, while top-right concepts are values that should be copied freely. The seesaw between those two corners is exactly the pair Change Value to Reference and Change Reference to Value.
🧹 Which smells does it cure?
| Smell | How this refactoring helps |
|---|---|
| Primitive Obsession | This is the primary cure — the rich concept finally gets a real type with real rules. |
| Data Clumps | The same move applied to a group: street + city + PIN become one Address object. |
| Duplicate Code | Scattered copies of validation and formatting collapse into one class. |
| Large Class | Host classes shed responsibilities that never belonged to them. |
| Shotgun Surgery | A change to phone formatting becomes one edit in PhoneNumber, not ten edits across the app. |
📦 Quick revision box
+--------------------------------------------------------------+
| REPLACE DATA VALUE WITH OBJECT — REVISION |
+--------------------------------------------------------------+
| Idea : A primitive with rules/behaviour becomes a small |
| class (scribbled number -> contact card). |
| AKA : Replace Primitive with Object (Fowler, 2nd ed.) |
| Signs : repeated validation, value logic in host classes, |
| parameter mix-ups, units/format confusion. |
| Steps : 1. Self-encapsulate the field |
| 2. Create wrapper class (raw value + getter) |
| 3. Host stores the object, callers unchanged |
| 4. Add validation in constructor |
| 5. Move behaviour in, one method at a time |
| 6. Make it immutable + value equality |
| Key idea : value object = "AN equal thing is good enough" |
| C# bonus : record / readonly record struct = value object |
| with equality and immutability for FREE. |
| Cures : Primitive Obsession, Data Clumps, duplication. |
+--------------------------------------------------------------+✍️ Practice exercise
A ticket system for a Mumbai local-train app stores everything as primitives:
class Ticket {
constructor(
public passengerName: string,
public fromStation: string,
public toStation: string,
public fareRupees: number, // sometimes someone passes paise by mistake!
public classCode: string, // "I" or "II" — but "VIP" sneaks in sometimes
) {}
fareWithSurcharge(): number {
return this.fareRupees * 1.05;
}
display(): string {
return `${this.fromStation} -> ${this.toStation} (${this.classCode}) Rs.${this.fareRupees}`;
}
}Your tasks:
- Promote
fareRupeesinto aMoneyvalue object that stores paise as integers, forbids negative amounts, and offerswithSurcharge(percent)returning a newMoney. - Promote
classCodeinto aTravelClasstype that only allows first or second class. (Hint: a class with a private constructor and two static instances, or a TypeScript union type wrapped with a validator.) - Move
display's formatting of fare intoMoney.toString(). - Write a small test proving two
Money.fromRupees(15)objects are equal — "AN equal thing", not "THE same thing". - Bonus in C#: implement
Moneyas areadonly record structandTravelClassas arecordwith staticFirstandSecondinstances. Count how many lines of equality code the compiler wrote for you. (Answer: all of them.) - Thinking question: the app also stores
passengerName. Should it become a value object too? Apply the sorting table from this article — does a name have rules or behaviour in this app? If not today, what future requirement (say, printing initials on the ticket) would tip the verdict?
If your Ticket constructor can no longer be called with a fare in paise or a "VIP" class — not because of a check you remembered to write, but because the types refuse — then you have done what Priya did to Ravi's notebook: made the wrong thing impossible to save.
Frequently asked questions
- Is this the same as 'Replace Primitive with Object'?
- Yes. In the first edition of Fowler's Refactoring book it was called Replace Data Value with Object. In the second edition it appears as Replace Primitive with Object. Same medicine, new label: take a bare string or number that has grown rules, and promote it into a small class.
- How do I know a value has 'earned' its own class?
- Watch for three signs: the value has rules (a phone number must have 10 digits), the value has behaviour (format it, compare it, mask it), or the same checks are copy-pasted wherever the value travels. One sign is enough to consider it; two or more means do it.
- Will I end up with hundreds of tiny classes?
- No, because most values never grow rules. A loop counter stays a number. You wrap only the values your business actually cares about — phone numbers, money, email, PIN codes. Those are usually a handful, and each one removes dozens of scattered checks.
- Should the new object be mutable or immutable?
- Almost always immutable. A value object represents a value, like the number 7 — you do not 'change' 7, you use a different number. Immutability gives free safety: no one can corrupt a phone number that another object also holds. C# records and TypeScript readonly fields make this easy.
- What about saving it to a database or JSON?
- That is the real cost of this refactoring. Your ORM or serializer must learn to map the object to a column or string. Most tools support this well (for example, value converters and owned types in EF Core), but budget a little time for the mapping at the boundaries.
Further reading
Related Lessons
Self Encapsulate Field: Let One Gatekeeper Guard Your Data
Self Encapsulate Field explained simply — why a class should read and write its own fields through getters and setters, with safe steps, TypeScript and C# examples.
Change Value to Reference: One Office File, Not Twenty Photocopies
Change Value to Reference explained simply — why duplicate copies of the same entity go stale, and how a shared single instance via a registry or repository keeps data consistent.
Change Reference to Value: Any ₹10 Note Is As Good As Another
Change Reference to Value explained simply — how to turn a shared, mutable reference object into a small immutable value object with content-based equality, with TypeScript and C# record examples.
Primitive Obsession: When Everything Is Just a String or a Number
Primitive Obsession explained simply — why plain strings and numbers hide bugs, and how value objects like Money and Address make code safe and clear.