Replace Exception with Test: Read the Board Before You Walk In
Learn the Replace Exception with Test refactoring (Replace Exception with Precheck) with a canteen story, before/after TypeScript and C#, TryParse-style patterns, the check-then-act race-condition trap, and Result types as the modern third way.
☕ Read the board before you walk in
Let me tell you about two students at an engineering college in Coimbatore, both hungry at odd hours.
Ravi has a system. Whenever he wants tea, he marches straight into the canteen, walks right up to the counter, and demands a tea. If the canteen is closed, the watchman — Murugan anna, a man of limited patience and a powerful voice — shouts at him: "CLOSED! Can't you see?!" — and Ravi walks back. This happens four or five times a day. Every time, the same drama: the long walk in, the shout, the embarrassed walk out past the grinning juniors. Murugan anna is exhausted. Ravi, with a straight face, calls this "checking if the canteen is open."
Meena, his classmate, has a different system. The canteen hangs a small board at the gate: OPEN or CLOSED, flipped every time the shutter moves. Meena glances at the board from the corridor on her way past. Board says OPEN — she walks in. Board says CLOSED — she turns to the library instead, no steps wasted. Two seconds, no drama, no shouting, and Murugan anna keeps his voice for real emergencies — like the day the kitchen actually caught fire, when his shout cleared the building in under a minute because everyone knew he never shouts for small things.
That last sentence is the heart of this whole post. The canteen being closed at 4 PM is not an emergency. It is a normal, expected, perfectly predictable condition. Using the watchman's shout to discover it is using an emergency system for a routine question — and worse, it slowly teaches everyone to ignore the emergency system.
In code, Ravi's method is a try/catch wrapped around an operation that throws for an expected situation. Meena's board is a cheap upfront check — if (canteen.isOpen()). Today's refactoring, Replace Exception with Test, converts Ravi's drama into Meena's glance.
📜 What is Replace Exception with Test?
Exceptions are wonderful for one job: announcing genuinely unexpected problems that must not be ignored. We praised them for exactly that in the twin post, Replace Error Code with Exception. But every powerful tool attracts misuse, and the classic misuse of exceptions is this: using try/catch to handle a situation that is ordinary, predictable, and cheap to check in advance.
A key missing from a map. An empty list. A string that may or may not be a number. A config value that may not be set. None of these are emergencies. They are Tuesdays. Code that handles Tuesdays with try/catch has three problems.
- It lies to the reader. A
try/catchblock says "something abnormal may happen here." When the "abnormal" thing is a missing default that occurs in half of all calls, the reader is misled about what is normal and what is rare. Control flow hides inside the catch, jumping invisibly instead of flowing visibly. - It is slow. Throwing an exception builds a stack trace and unwinds the call stack — work that costs hundreds to thousands of times more than a simple comparison. Microsoft engineers list "exceptions for control flow" among the classic performance sins, and real-world reports exist of imports that "ran forever" becoming fast simply by replacing throw-per-row with a precheck.
- It cheapens real alarms. If exceptions fire all day for routine reasons, nobody respects them. Logs fill with noise. The one exception that signals true corruption drowns in a thousand fake ones. The watchman who shouts hourly is ignored during the fire.
Replace Exception with Test says: find the condition the catch block is quietly compensating for, and test for it explicitly, before acting. Martin Fowler's second edition renames it Replace Exception with Precheck, which describes the move perfectly — put a precheck in front, remove the catch.
One-line summary: if you can cheaply ask "is the canteen open?" before walking in, ask — and save the watchman's shout (the exception) for things nobody could have checked.
These two twin refactorings are not enemies; they are the two hands of one rule. Expected condition → check first. Unexpected problem → throw. Mastering the pair means knowing which situation you are looking at. And because the modern world added a third and even a fourth hand, here is the full toolbox in one picture before we go deeper.
The four tools also fit in a single decision table — bookmark this one; it answers most code-review arguments about error handling.
| Situation | Right tool | Canteen translation |
|---|---|---|
| Expected condition, cheap safe check, state you own | Precheck (if) — this refactoring | Glance at the board |
| Expected condition, shared state that can change under you | Atomic Try-style call, handle the no | Knock once; if no answer, move on |
| Expected failure that carries a reason worth returning | Result / Either type | Board says CLOSED TILL 5 — EXAM SPECIAL AT 5.30 |
| Genuinely abnormal, nobody could have checked | Exception — the twin refactoring | The kitchen is on fire |
🔍 When do we need it?
Watch for these patterns in real code.
- try/catch around lookups. Catching the failure of a map/dictionary access, an array index, or a property read — when
has(),length, or a null check could have answered first. - Catch blocks that just return a default.
catch { return DEFAULT_PRICE; }is the classic giveaway. A default value is the very opposite of an emergency. - Parsing with catch. Wrapping
parseInt-style orParse()-style calls in try/catch when aTryParse/ validity check exists. User input that may not be a number is expected — that is why users have keyboards. - Exceptions firing in loops. Profilers and logs showing thousands of identical exceptions per minute. Each one is a watchman's shout for a Tuesday.
- "Expected exception" comments. When the code itself says
// this is normal, ignoreinside a catch block, the author knew the truth and used the wrong tool anyway.
When the canteen app's team finally audited a week of production logs, they classified every exception that had been thrown. The result is the single most persuasive chart in this post.
Ninety-five percent of the shouting was about Tuesdays. The five percent of real failures — a corrupted price file, one database timeout — were buried under thousands of routine entries, and on the day the price file corrupted, nobody noticed for six hours because the error log always looked like that. Alert fatigue is not a metaphor; it is a measurable production risk.
And the signs that you should not do this refactoring — read these just as carefully.
- The state can change between check and act. You check the file exists; another process deletes it; your open call explodes anyway. In concurrent or shared-resource situations, check-then-act has a built-in race condition (the classic TOCTOU — time-of-check to time-of-use — problem). There, attempting the operation and catching the failure is the correct, atomic approach, and the precheck is a false comfort.
- No cheap test exists. If the only way to know whether the operation will succeed is to basically perform the operation, a precheck doubles the work. Keep the exception or use a Try-style API that does both jobs in one pass.
- The condition is genuinely abnormal. Do not "fix" an exception that signals real corruption by quietly defaulting over it — that buries a real bug. That direction is the territory of the twin post.
The canteen story even contains the race condition, if you look closely. Meena's board works because the board and the shutter are flipped together, by the same person, at the same moment. But suppose Meena reads OPEN, walks two corridors, and the canteen closes while she walks — the board was true at check time and false at use time. For a two-minute walk in a college, who cares; she shrugs and leaves. For two threads and a shared file, the same gap is a bug. Here is the canteen as a state machine, with both students' paths drawn in.
⚖️ Before and after at a glance
Here is the canteen app's price lookup, before. A missing item code is utterly normal — new items get added weekly — yet the code treats it as a disaster.
// BEFORE: an expected case handled by emergency machinery
const DEFAULT_PRICE = 10;
function unitPrice(menu: Map<string, number>, itemCode: string): number {
try {
const price = menu.get(itemCode);
if (price === undefined) {
throw new Error(`No price for ${itemCode}`); // shout!
}
return price;
} catch {
return DEFAULT_PRICE; // ...and immediately apologize for shouting
}
}Read it honestly: the function throws an exception at itself and catches it three lines later, purely to deliver a default. Ravi walks in just to get shouted out. After the refactoring:
// AFTER: glance at the board first
const DEFAULT_PRICE = 10;
function unitPrice(menu: Map<string, number>, itemCode: string): number {
return menu.get(itemCode) ?? DEFAULT_PRICE;
}One line. The normal flow is visible at a glance, there is no hidden jump, no stack trace is ever built, and exceptions in this module once again mean something is actually wrong. (In TypeScript the ?? operator plays the role of the explicit has() check; in other languages you would write if (menu.has(itemCode)) — same idea, same refactoring.)
The two students' approaches, side by side, in sequence form — count the arrows each one needs.
🪜 Step-by-step, the safe way
The mechanics are short, but each step has a thinking part. Follow Fowler's order.
Step 1: Identify exactly which condition the catch is compensating for. Read the catch block and finish this sentence: "we end up here whenever ___." If the answer is a routine situation ("the item is not in the menu yet"), continue. If the answer includes anything unpredictable ("the database connection dropped"), stop — that part must stay an exception.
Step 2: Find or build a cheap, side-effect-free test for that condition. map.has(key), array.length > 0, dict.ContainsKey, value !== undefined, Number.isFinite(n). The test must be safe to call without changing anything, and cheap enough to run on every call.
Step 3: Add the test in front, while keeping the catch. This is the safe intermediate state — belt and braces. The new guard handles the expected case; the old catch still sits behind it, temporarily.
// INTERMEDIATE: guard added, catch still present as a safety net
function unitPrice(menu: Map<string, number>, itemCode: string): number {
if (!menu.has(itemCode)) {
return DEFAULT_PRICE; // new: the board
}
try {
const price = menu.get(itemCode);
if (price === undefined) throw new Error(`No price for ${itemCode}`);
return price;
} catch {
return DEFAULT_PRICE; // old: should now be unreachable
}
}Step 4: Prove the catch is dead, then remove it. Run the tests. If you want extra confidence in production code, log inside the catch for one release — silence in the logs is your proof. Then delete the try/catch and the artificial throw.
Step 5: Re-check for races and atomicity. One last honest look: can anything change between your test and your use? For an in-memory map owned by one thread — no, you are safe. For files, networks, databases, shared caches — possibly yes, and then you revert to catching (or use an atomic Try-style API).
Step 6: Test three cases. The present case, the absent case, and the boundary (empty map, empty string, zero).
The check-then-act race is the one place this refactoring can introduce a bug that was not there before. if (file.exists()) file.open() looks cleaner than try/catch — and is wrong on a shared disk, because the file can vanish between the two lines. The same trap exists for "check seat available, then book seat" in any multi-user system. Rule of thumb: prechecks are perfect for data you exclusively own in memory; for shared, external, or concurrent resources, prefer a single atomic operation and handle its failure.
College corner: why throwing costs so much, and why Python disagrees anyway. Modern runtimes implement zero-cost exception handling: a try block costs nothing to enter, because handler locations live in static tables. The price is paid entirely at throw time — capturing the stack trace, walking unwind tables frame by frame, running cleanup, matching handler types. A throw is microseconds; a comparison is nanoseconds; the ratio is commonly two to four orders of magnitude. This is why "exceptions as control flow" appears on every performance-sin list. Now the counterpoint your Python friends will raise: Python culture famously prefers EAFP — "easier to ask forgiveness than permission" — meaning try/except KeyError is considered idiomatic where C# would precheck (LBYL, "look before you leap"). Two facts make peace between the camps. First, Python's exceptions are far cheaper than .NET's or the JVM's, so the performance argument is weaker there. Second, EAFP is partly a correctness argument: in concurrent situations, try-and-ask-forgiveness is atomic where check-then-act races — the same TOCTOU point our warning callout makes. So the deep rule is language-independent: measure the cost, respect atomicity, and let the expected case read like an expected case. In C# and TypeScript, that almost always means the precheck or a Try-method; in Python, a tight except KeyError around one line is acceptable style — but a broad except Exception returning a default is bad in every language ever invented.
🍽️ A bigger real-life example
The canteen app has a billing screen with a real performance story. Students type item codes at the counter, and at month-end the app imports thousands of scribbled paper-register rows, many of them malformed. The first version used exceptions for everything.
// BEFORE: three expected situations, three emergency responses
function billRow(menu: Map<string, number>, rawCode: string,
rawQty: string): number {
let qty: number;
try {
qty = strictParseInt(rawQty); // throws on "two", "", "3 cups"
} catch {
qty = 1; // expected: messy handwriting
}
let price: number;
try {
price = mustGet(menu, rawCode); // throws when code unknown
} catch {
price = DEFAULT_PRICE; // expected: new items
}
try {
return applyDiscount(price * qty); // throws when no discount set
} catch {
return price * qty; // expected: most days
}
}On a 50,000-row import where a third of the rows are messy, this function throws tens of thousands of exceptions — each one building a stack trace for a Tuesday. The import crawled. The refactored version asks first.
// AFTER: glance at each board; exceptions retired from routine duty
function billRow(menu: Map<string, number>, rawCode: string,
rawQty: string): number {
// 1) Parse with a test, not a trap
const parsed = Number.parseInt(rawQty, 10);
const qty = Number.isNaN(parsed) ? 1 : parsed;
// 2) Look before you get
const price = menu.get(rawCode) ?? DEFAULT_PRICE;
// 3) Ask whether a discount exists before applying it
const discount = discountFor(rawCode); // returns rate or null
const total = price * qty;
return discount === null ? total : total * (1 - discount);
}Every branch is now visible. A new programmer reads the function top to bottom and sees the actual shape of the business: messy quantities default to one, unknown items get the default price, discounts are optional. Nothing jumps, nothing unwinds, and the import runs at memory speed. And note what we did not silence: if the database connection fails inside discountFor, that exception still flies — because that one is a fire, not a Tuesday.
The team timed the month-end import before and after, on the same 50,000-row register file with the usual one-third messy rows.
Ninety-six seconds to four. No algorithm changed, no hardware changed — the only thing removed was tens of thousands of stack traces built to answer routine questions. This is the most common "free" performance win in business codebases, and profilers find it for you: look for exception counts in the thousands per minute.
💼 The same refactoring in C#
.NET makes this refactoring especially pleasant, because the standard library ships Try-style methods — prechecks and actions fused into one atomic, race-free call.
// BEFORE: exceptions doing routine work
public decimal UnitPrice(Dictionary<string, decimal> menu, string code)
{
try
{
return menu[code]; // KeyNotFoundException when absent
}
catch (KeyNotFoundException)
{
return DefaultPrice; // ...but absence is normal!
}
}
public int ParseQuantity(string raw)
{
try
{
return int.Parse(raw); // FormatException on "two cups"
}
catch (FormatException)
{
return 1;
}
}// AFTER: TryGetValue and TryParse — the check and the act in ONE step
public decimal UnitPrice(Dictionary<string, decimal> menu, string code)
{
return menu.TryGetValue(code, out var price) ? price : DefaultPrice;
}
public int ParseQuantity(string raw)
{
return int.TryParse(raw, out var qty) ? qty : 1;
}The Try-pattern deserves a special salute. TryGetValue and TryParse are better than a separate check-then-act pair: they perform one single lookup (not two — ContainsKey followed by the indexer searches the dictionary twice), and they are atomic, so the race-condition worry evaporates. .NET even ships an analyzer, CA1854, that flags ContainsKey + indexer pairs and suggests TryGetValue. The performance gap is not academic — Microsoft's own guidance lists exception-driven control flow as a known performance sin, and int.TryParse versus catch-around-int.Parse on dirty data can differ by several orders of magnitude on hot paths.
When you design your own APIs in C#, offer the same pair the framework does: a throwing version for callers who consider absence a bug (GetPrice), and a Try-version for callers who consider absence a Tuesday (TryGetPrice). Then each caller picks the tool that matches their expectation. The shape of such an API looks like this:
Read the two arrows: the billing screen treats an unknown code as a bug (a cashier scanned something not in the system — investigate!), so it calls the throwing version. The import job treats unknown codes as routine handwriting mess, so it calls the Try-version. Same data, different expectations, different doors — and both callers are right.
For balance, here is the same lesson in Python, where the dictionary's get method with a default is the precheck-and-act fused into one call, exactly like TryGetValue:
DEFAULT_PRICE = 10
# Ravi style: emergency machinery for a routine question
def unit_price_ravi(menu: dict[str, int], code: str) -> int:
try:
return menu[code] # raises KeyError when absent
except KeyError:
return DEFAULT_PRICE
# Meena style: one atomic ask-with-default
def unit_price_meena(menu: dict[str, int], code: str) -> int:
return menu.get(code, DEFAULT_PRICE)
# Parsing with a test instead of a trap
def parse_qty(raw: str) -> int:
return int(raw) if raw.strip().isdigit() else 1Even in EAFP-friendly Python, menu.get(code, default) is universally preferred over try/except here — because when a one-line atomic tool exists that says exactly what it means, every culture agrees it wins.
🛠️ IDE support
No IDE offers a one-click "Replace Exception with Test" — recognizing that a condition is expected requires human judgment. But the surrounding tooling is strong.
- .NET analyzers / Visual Studio / Rider: rule CA1854 ("Prefer Dictionary.TryGetValue") rewrites double-lookup patterns via Quick Fix; IDE suggestions nudge
int.Parse+ catch towardint.TryParse; ReSharper flags catch clauses that only return a default. - SonarLint / SonarQube (Java, C#, TypeScript): dedicated rules against using exceptions for control flow and against catch blocks that swallow expected outcomes — useful as a team-wide watchdog after the cleanup.
- IntelliJ IDEA: inspections such as "Catch block may ignore exception" and "Exception used for control flow" highlight candidates for this exact refactoring.
- TypeScript + ESLint:
no-emptycatches silent catch blocks; the compiler's strict null checking plus??and optional chaining often dissolve the whole try/catch into one expression, as our one-liner showed. - Profilers as detectors: Visual Studio's Events view, dotnet-trace, and Chrome DevTools all show exception counts — a spike of thousands of identical exceptions per minute is this refactoring's loudest invitation.
⚖️ Benefits and risks
The honest ledger — and remember, this post and Replace Error Code with Exception are one lesson in two halves: checks for expected conditions, exceptions for unexpected problems. This table is about applying the check side correctly.
| Benefits | Risks / costs |
|---|---|
| Normal flow becomes visible, top-to-bottom readable — no hidden jumps into catch blocks | Check-then-act races (TOCTOU): on shared resources the state can change after the check — keep the atomic catch there |
| Avoids stack-trace building and unwinding — orders of magnitude faster on hot paths | If no cheap test exists, the precheck may duplicate the operation's work |
| Exceptions regain their meaning: a throw once again signals something truly wrong | Overdone defaulting can mask real bugs — quietly papering over corruption is worse than throwing |
Usually shorter: a ternary or ?? replaces five lines of try/catch ceremony | Guards duplicated across many callers become their own smell — centralize them |
| Logs and monitoring stop drowning in routine "expected exceptions" | Judgment required: misclassifying a real anomaly as "expected" buries a fire alarm |
How do you make that judgment quickly and consistently? Two questions: is the condition expected? and do you exclusively own the state, or is it shared and changeable? Plot any situation on those axes and the right tool reads straight off the map.
Bottom-left is this refactoring's home ground: expected conditions on state you own — precheck happily. Bottom-right is the Try-method zone: expected, but shared — one atomic call, no gap to race through. The top half belongs to the twin post: genuinely unexpected things should throw, whether the state is yours (a broken invariant — a bug to fix) or shared (corruption — catch at a boundary, alert a human).
College corner: Result and Either types, the third way stated honestly. Result types — Rust's Result<T, E>, Either in fp-ts, typed unions in plain TypeScript, OperationResult-style libraries in C# — shine exactly in the middle ground this post lives in: expected failures that nevertheless carry information worth returning ("rejected because the limit is 3", not just "no"). A boolean precheck answers yes/no; a Result carries the reason, and the compiler refuses to let callers ignore it — Rust famously will not compile code that touches a Result without handling the error arm, and its ? operator makes passing failures upward a single character, solving the ceremony problem exceptions were invented to solve. The costs, stated equally honestly: in languages built around exceptions, every layer must unwrap or forward the Result by hand; library boundaries still throw, so you end up wrapping; and a half-adopted style (some functions throw, some return Results) confuses more than either pure style. The balanced modern recipe: simple presence questions → precheck or Try-method; expected domain failures with reasons → Result type; genuine anomalies → exception. Three tools, three jobs, no overlap — and the engineer's skill is the classification, not the syntax.
🧹 Which smells does it cure?
| Smell | How this refactoring helps |
|---|---|
| Exceptions as control flow | Directly removes the anti-pattern — routine branching returns to if, where readers can see it |
| Long Method | Five-line try/catch ceremonies collapse into one-line guards, shrinking methods dramatically |
| Duplicate Code | Identical catch-and-default blocks copy-pasted around every lookup become a single shared guard or helper |
| Noisy logs / alert fatigue | Expected conditions stop generating stack traces, so monitoring shows only real anomalies |
| Comments explaining "this exception is normal" | The code now says it directly — an honest if needs no apology comment |
📋 Quick revision box
+------------------------------------------------------------------+
| REPLACE EXCEPTION WITH TEST (PRECHECK) - REVISION CARD |
+------------------------------------------------------------------+
| Problem : try/catch handling a condition that is ordinary, |
| predictable, and cheap to check (missing key, |
| empty list, unparseable input) |
| Solution : test the condition UP FRONT with a plain if; |
| keep exceptions for the truly unexpected |
| |
| ASK FIRST: |
| expected + cheap check + private state -> PRECHECK (this) |
| shared resource, state may change -> act + catch (race!) |
| no cheap check exists -> Try-method / Result |
| genuinely abnormal -> keep the EXCEPTION |
| |
| THE PAIR : this post = stop shouting about Tuesdays |
| twin post = start shouting about real fires |
| C# bonus : TryGetValue / TryParse = check + act, atomic, fast |
| (analyzer CA1854 finds double lookups for you) |
+------------------------------------------------------------------+✏️ Practice exercise
Your turn. A school attendance app marks students present by roll number, and the original author loved try/catch a little too much.
function markPresent(register: Map<number, Student>, roll: number): string {
try {
const student = register.get(roll)!;
student.presentDays += 1; // throws on undefined
return `Marked: ${student.name}`;
} catch {
return "Unknown roll number"; // expected: typos happen daily
}
}
function averageMarks(marks: number[]): number {
try {
return marks.reduce((a, b) => a + b) / marks.length; // reduce throws on []
} catch {
return 0; // expected: new student, no marks
}
}Do the refactoring yourself, step by step:
- For each function, finish the sentence "we reach the catch whenever ___" and say whether that situation is expected or abnormal.
- Refactor
markPresentwith an upfrontregister.has(roll)check (or aget+undefinedtest). Remove the try/catch and the dangerous!. - Refactor
averageMarkswith amarks.length === 0guard. Remove the try/catch. - Write three tests for each function: the normal case, the absent/empty case, and one boundary of your choice.
- Plot both situations on the Figure 9 quadrant chart. Both should land bottom-left — explain in one sentence why the in-memory
registermap makes the race-condition worry irrelevant here. - Bonus thinking: a teammate says, "Let's also wrap the database save in a precheck —
if (db.isConnected()) db.save()." Explain in two sentences why this precheck is a false comfort, and what the correct tool is. (Hint: who else is using that connection, and what can happen between the check and the save?) - Stretch goal: redesign
markPresentto return a Result-style union —{ ok: true; name: string } | { ok: false; reason: "unknown-roll" }— and write one sentence on what the compiler now forces the calling screen to do that a plain string return never could.
If you can explain to a friend why the canteen board is kinder than the watchman's shout — and also why the board cannot protect Meena when someone else can shut the canteen while she walks — you have understood this refactoring, and its limits, completely.
Frequently asked questions
- What does Replace Exception with Test actually mean?
- It means: if a so-called failure is really a normal, expected condition that you can cheaply check in advance — a missing key, an empty list, a parseable string — then check it with a plain if before acting, instead of letting the code throw and catching the exception. Exceptions stay reserved for genuinely unexpected problems.
- Does this refactoring have another name?
- Yes. Martin Fowler's second edition of Refactoring calls it Replace Exception with Precheck, which describes the mechanics nicely — you add a precheck before the risky operation. Older sources and refactoring.guru use the original name, Replace Exception with Test.
- When is the upfront test the wrong choice?
- Two cases. First, when the state can change between your check and your action — like checking a file exists and then opening it after another process deleted it. There, catching the exception is the only atomic, correct option. Second, when no cheap side-effect-free check exists, so testing would mean doing the operation twice.
- How much faster is a check than a thrown exception?
- Dramatically. Throwing captures a stack trace and unwinds the stack, which can be hundreds to thousands of times slower than a simple comparison. On a hot loop — parsing a million rows — exceptions as control flow can turn minutes of work into hours.
- How do Result types fit into this picture?
- A Result or Either type is the third way: the operation returns an object that is either a success with a value or a typed failure. It suits expected failures that carry information, and the compiler forces handling. The upfront test suits simple presence checks; exceptions suit the truly unexpected; Results sit in between.
Further reading
Related Lessons
Replace Error Code with Exception: Stop Whispering Failures, Announce Them
Learn the Replace Error Code with Exception refactoring with a government-office story, before/after TypeScript and C#, safe migration steps, and an honest comparison with Result types as the modern third way.
Long Method: When One Function Tries to Do Everything
Learn the Long Method code smell with simple stories, TypeScript and C# examples, and step-by-step refactoring using Extract Method. Beginner friendly guide.
Duplicate Code: Writing the Same Address on 50 Wedding Cards
Learn the Duplicate Code smell with a wedding card story. Understand DRY, the Rule of Three, and how Extract Method removes dangerous copy-paste code.
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.