July 29, 2026
The Bug That Wasn’t a Bug in the Code
Code Bugs

By JIN
14 min read
Disclosure: I use GPT search to collection facts. The entire article is drafted by me.
The Coupon Ticket That Ate a Sprint
Here's a story every backend engineer has lived through in some form.
Product asks for one change: "Coupons shouldn't stack with the membership discount anymore." Sounds like a two-line fix. You open the codebase and search for where coupon logic lives.
You find CouponService. Fine. Then CouponManager, which apparently does something slightly different. Then CouponBiz, a leftover from a rewrite nobody finished. Then the rule gets re-implemented inline inside OrderService, SettlementService, and PromotionService, because at some point someone needed "just this one case" and copying was faster than importing.
Forty-something places, by the end of the grep.
You fix thirty-nine of them. The fortieth — buried inside a batch settlement job that runs at 2 a.m. — still allows stacking. Three weeks later, finance notices a margin leak. Nobody touched the code that broke. The code was fine. It just hadn't heard the news.
That's the moment I want you to sit with, because the instinct is to call this a code duplication problem. It isn't, not really. What actually happened is that a single piece of business knowledge — "coupons and membership discounts are mutually exclusive" — got copied into forty places as forty separate beliefs, and one of those beliefs never got updated. The bug wasn't in a function. It was in the fact that the same truth existed in forty independent forms, and the world only remembered to correct thirty-nine of them.
This is the real subject of this piece. Not how to write less code. How to stop knowledge from living in more than one place.
Chapter 1 — DRY Was Never About Code
Everyone knows the acronym: Don't Repeat Yourself. Almost everyone misapplies it, because almost everyone reads it as "don't repeat code."
Go back to where the term actually comes from — Hunt and Thomas's The Pragmatic Programmer — and the definition is more precise than the folklore version: every piece of knowledge must have a single, unambiguous, authoritative representation in the system. Not "every line of code." Knowledge.
That word choice is deliberate. Code is just one carrier of knowledge — one of several. The same fact can also live in:
- A database schema constraint
- A validation rule in three different microservices
- A comment explaining "why this weird check exists"
- A config file
- Someone's memory of "oh yeah, finance always rounds down"
DRY, properly understood, isn't a style preference about avoiding Ctrl+C / Ctrl+V. It's a claim about system integrity: if a fact about how the business works exists in more than one place, those copies will drift, because nothing forces them to stay synchronized except human diligence — and human diligence has a well-documented failure rate of exactly 100%, eventually.
This reframing matters because it changes what you're hunting for. You stop asking "where is duplicated code?" and start asking "where does the same business fact get independently re-derived?" Those are very different searches, and the second one is the one that actually prevents 2 a.m. margin leaks.
Chapter 2 — Why Duplication Keeps Winning
If duplication is this dangerous, why does every real codebase have so much of it? Not because engineers are lazy — because duplication is, locally and short-term, the rational choice under real constraints.
Deadline pressure. The feature ships tonight. Refactoring the existing SettlementService to absorb the new rule is a two-day job with regression risk. Copying the block and tweaking three lines is twenty minutes. Under a delivery clock, the twenty-minute option wins every time, and it should — the alternative isn't "do it properly," it's "miss the deadline."
Uncertainty about the future. You genuinely don't know if this new business rule is a one-off exception or the start of a pattern. Abstracting too early, before you know the shape of the variation, often produces something worse than duplication (more on this in Chapter 4). So teams copy first and wait to see if a real pattern emerges.
Team boundaries. In any org past a certain headcount, the payments team and the order team and the membership team do not read each other's pull requests. Nobody duplicates on purpose — they simply don't know a working version already exists three repos over. This is an information problem, not a discipline problem.
Microservices, ironically, make it worse. Splitting a monolith into services is often sold as a cleanliness upgrade. In practice, each service now maintains its own DTOs, its own validators, its own copy of "how do we format a phone number," its own converter utilities. The service boundary — which exists to isolate deployment — accidentally isolates knowledge too, and nothing forces the isolated copies to agree with each other.
Technical debt with tenure. A ten-year-old codebase has code nobody currently on the team wrote, tied to business rules nobody currently on the team remembers negotiating. Deleting it feels like defusing a bomb blindfolded. So it stays, and its logic gets copied forward into new features rather than touched.
None of this is a competence failure. It's what happens when software complexity accumulates faster than any team's shared understanding of the system. Duplication isn't the disease. It's a symptom that shows up reliably whenever organizational memory can't keep pace with organizational growth.
Chapter 3 — Four Layers of the Same Problem
To actually diagnose duplication, you need a taxonomy, because "text duplication" and "the whole company reinventing its own auth system" are the same disease at wildly different scales.
Layer 1 — Text duplication. The most cosmetic layer: repeated boilerplate like BeanUtils.copyProperties() calls, near-identical try/catch blocks, repeated logging statements, repeated parameter validation. This is the layer most tutorials fixate on, and it's genuinely the easiest to fix — utility methods, base classes, code generation.
Layer 2 — Workflow duplication. The steps are the same; the implementation inside each step differs. Price calculation → discount calculation → final amount, repeated across three product lines with slightly different discount logic inside each step. This is where template method and pipeline patterns earn their keep — you fix the shape once, and let each implementation vary only where it needs to.
Layer 3 — Business rule duplication (the real target). This is knowledge duplication proper. Red packets, loyalty points, membership tiers, promotional stacking rules — these all look like separate features but are frequently the same handful of underlying rules ("can this benefit combine with that benefit," "what's the priority order when multiple discounts apply") reimplemented per feature team. This layer is invisible to a code-similarity scanner, because the code doesn't look alike. Only the meaning is duplicated.
Layer 4 — Organizational duplication. Zoom out past a single codebase: every team in a mid-size company ends up building its own user directory, its own permission checks, its own login flow, because nobody centralized it early enough. This is the layer that gives birth to platform teams and internal "capability platforms" — which, whether anyone frames it this way or not, are DRY applied at the org-chart level. If Layer 3 is about a single truth for a rule, Layer 4 is about a single truth for an entire capability.
Chapter 4 — Should You Even Abstract This?
This is the chapter most DRY advice skips, and it's the one that actually separates senior engineers from people who just read a blog post about design patterns.
The classic heuristic is the Rule of Three, often attributed to (or at least popularized through) Kent Beck's writing on implementation patterns: the first time you write something, write it. The second time you need something similar, notice it, but don't act yet. The third time, now you extract the abstraction — because now you actually have enough examples to know what varies and what's stable.
Why wait? Because two data points don't tell you the shape of a pattern — they only tell you two things happened to look similar once. Abstracting after one repetition means you're guessing at the general case from a sample size of two, and that guess is very often wrong.
This is where AHA — Avoid Hasty Abstractions — earns its place next to DRY, not against it. AHA isn't "duplication is good." It's "premature generalization is a worse failure mode than duplication, because a wrong abstraction actively resists the correction that duplicated code would have accepted easily."
Concrete version of the trap: two API endpoints look identical today, so you merge them into one shared handler. Six months later, one of them grows three new edge cases specific to a new market, and the other stays simple. Now your "shared" handler is full of if (market === 'MY') { ... } else { ... } branches serving a case that was never actually shared — it just happened to look shared on the day you merged it. You've paid the interest on a false unification, and every future change to either endpoint now requires reasoning about both.
I lean toward Sandi Metz's framing here, because it's the sharpest version of this argument: duplication is far cheaper than the wrong abstraction. Duplicated code costs you extra typing and a slightly larger diff. A wrong abstraction costs you every future feature that has to be built around a shape that no longer matches reality. One of those costs is linear. The other compounds.
So the actual decision isn't "DRY vs. duplication." It's: do I understand this pattern well enough yet to commit to a shared shape, or am I still watching it settle? If you don't know, the honest answer is almost always: wait. Let the third occurrence teach you something the first two couldn't.
Chapter 5 — Removing Duplication, Layer by Layer
Once you know which layer you're actually fighting (Chapter 3) and you've earned the right to abstract (Chapter 4), the toolkit is genuinely layered by seriousness. Escalating past the layer you're in is where most over-engineering originates.
Tool tier 1 — utility-level abstraction. Assert helpers, validators, response builders, logging wrappers, bean-copy utilities, a single exception-handling convention. This tier removes mechanical repetition and nothing more. It reduces typing, not thinking.
Cautionary note: Assert-style helpers get misused constantly for business failures. Insufficient stock or insufficient balance is not "an assertion failed" — it's an expected business outcome that needs a proper domain exception with a message the caller can act on, not a stack trace that treats a normal business state like a programmer error.
Tool tier 2 — workflow abstraction. Template method, strategy pattern, chain of responsibility, pipeline composition. Order settlement, coupon calculation, and promotional stacking often share an identical sequence of steps with different logic inside each step — this is exactly the shape template method exists for.
Cautionary note: template method inheritance hierarchies get deep fast, and deep inheritance is its own maintenance tax. Strategy pattern avoids the inheritance problem but can quietly produce "strategy object explosion" — forty strategy classes for forty near-identical rule variants is not meaningfully better than forty copies of an if block; you've just renamed the duplication and added indirection on top.
Tool tier 3 — behavioral abstraction (cross-cutting concerns). AOP, interceptors, filters. Logging, permission checks, idempotency, rate limiting, timing, transaction boundaries — things that apply across many otherwise unrelated pieces of business logic.
Cautionary note, and this one matters a lot: AOP must never carry actual business logic. I've seen teams put discount-eligibility rules inside an aspect because "it runs everywhere we need it to." Six months later nobody can find where the rule lives, because nobody thinks to check an aspect when debugging business logic — aspects are supposed to be invisible plumbing, and business logic hiding inside invisible plumbing is a debugging nightmare by design.
Tool tier 4 — rule abstraction. Rule engines (Drools, Easy Rules), rule DSLs, externally configured business rules. This tier is justified only when rules change frequently, are owned by non-engineers (product/ops configuring thresholds or eligibility windows), and genuinely vary independently of deployment cycles.
Cautionary note: this is the most over-adopted tool on this list. Teams reach for Drools to manage ten if statements, discover the learning curve and debugging opacity are brutal, and six months later quietly migrate everything back into plain code — except now they've also paid the cost of the failed experiment.
Tool tier 5 — domain abstraction. DDD concepts — aggregates, domain services, value objects — used to give a business concept exactly one authoritative home in the codebase. This is the tier that actually resolves Layer 3 knowledge duplication properly, because it's not just deduplicating code; it's deduplicating meaning: "membership eligibility" becomes one object that everything else asks a question of, rather than a fact re-derived independently in five services.
Here's a minimal TypeScript sketch of tier 2 → tier 5 in miniature — going from copy-pasted settlement logic to a single rule authority:
// Tier 5: one authoritative source for "can these two benefits combine?"
// Every settlement path asks this object — nobody re-derives the rule.
interface BenefitCombinationRule {
canCombine(a: BenefitType, b: BenefitType): boolean;
}
class PromotionPolicy implements BenefitCombinationRule {
private readonly exclusions: Set<string> = new Set([
"COUPON:MEMBERSHIP_DISCOUNT",
]);
canCombine(a: BenefitType, b: BenefitType): boolean {
const key = [a, b].sort().join(":");
return !this.exclusions.has(key);
}
}
// Tier 2: shared workflow shape, policy injected - not re-implemented per service
class SettlementPipeline {
constructor(private policy: BenefitCombinationRule) {}
apply(order: Order, benefits: Benefit[]): Order {
const active = benefits.filter((b, i) =>
benefits.every((other, j) =>
i === j || this.policy.canCombine(b.type, other.type)
)
);
return active.reduce((acc, b) => b.applyTo(acc), order);
}
}// Tier 5: one authoritative source for "can these two benefits combine?"
// Every settlement path asks this object — nobody re-derives the rule.
interface BenefitCombinationRule {
canCombine(a: BenefitType, b: BenefitType): boolean;
}
class PromotionPolicy implements BenefitCombinationRule {
private readonly exclusions: Set<string> = new Set([
"COUPON:MEMBERSHIP_DISCOUNT",
]);
canCombine(a: BenefitType, b: BenefitType): boolean {
const key = [a, b].sort().join(":");
return !this.exclusions.has(key);
}
}
// Tier 2: shared workflow shape, policy injected - not re-implemented per service
class SettlementPipeline {
constructor(private policy: BenefitCombinationRule) {}
apply(order: Order, benefits: Benefit[]): Order {
const active = benefits.filter((b, i) =>
benefits.every((other, j) =>
i === j || this.policy.canCombine(b.type, other.type)
)
);
return active.reduce((acc, b) => b.applyTo(acc), order);
}
}The point of this snippet isn't the pattern — it's that PromotionPolicy is now the one place that knows the exclusion rule exists. Order, settlement, and coupon services no longer each carry their own copy of that fact; they ask.
Chapter 6 — Watching a System Actually Evolve
Take one system through its real lifecycle rather than jumping straight to "just use DDD," because that jump is exactly the kind of hasty abstraction Chapter 4 warns against.
V1 — Three Settlement classes, one per business line, each copy-pasted from the last because each launched under deadline pressure and nobody had seen three of them yet to know a pattern existed.
V2 — Someone finally hits the Rule of Three. The third settlement type arrives, the team notices all three follow the identical five-step sequence with different math inside each step, and extracts a template method. Code shrinks; bugs that used to require three fixes now require one.
V3 — The steps themselves start varying independently (some settlements need four steps, some need six, some need steps in different orders). Template method's rigid step-sequence starts fighting the new variation, so the team migrates to strategy + pipeline composition, trading inheritance rigidity for object composition flexibility.
V4 — Product and ops start asking to configure exclusion rules themselves, without waiting for a deploy. This is the actual trigger for tier 4 — not "rules exist," but "rules need to change on a cadence engineering can't service." A rule center gets built, with a config UI ops can use directly.
V5 — The concept of "a benefit" itself stabilizes into a proper domain object — value object, invariants, one authoritative combination policy — because by now the team has enough scar tissue to know exactly what varies and what's permanent. This is DDD arriving last, earned through four iterations of contact with reality, not imposed on day one because a book said domain models are good practice.
The point of walking through all five versions isn't nostalgia — it's that the "correct" architecture at V1 didn't exist yet, because the knowledge needed to design it didn't exist yet either. Abstraction quality is downstream of pattern observation, and observation takes time and repetition. Anyone who tells you to start at V5 is skipping the part where the system teaches you what V5 should actually look like.
Chapter 7 — When Refactoring Is the Wrong Move
This chapter needs to exist because everything above can read as "always eliminate duplication," and that's not the argument.
Sometimes two pieces of logic look identical today and are headed in genuinely different directions. If you merge them now, you're not saving future work — you're creating a shared object that two unrelated futures will fight over. Every time one side needs a change, someone has to verify it doesn't break the other side, forever, for a shared abstraction that was never really shared in the first place — it just happened to match on the day someone got impatient and unified it.
Sandi Metz's line again, because it's worth repeating in this exact spot: duplication is far cheaper than the wrong abstraction. Duplicated code costs you a slightly bigger diff. Wrong abstraction costs you an if (market === 'X') branch inside a "generic" function for the rest of that function's life, plus every engineer's confusion about what the function is actually for now.
Fowler's Refactoring famously names Duplicate Code as the very first "code smell," and it's genuinely a smell worth chasing — but a smell is a signal to investigate, not an automatic verdict. Sometimes the investigation concludes "these are actually two different facts that happen to share a sentence structure," and the correct outcome is: leave them separate.
Chapter 8 — From Code to Architecture
Zoom all the way out, and the same principle reappears at company scale, just wearing different clothes. "Platform team," "shared services," "capability center" — payments, membership, SMS, risk scoring, identity/auth — these aren't organizational fashion. They're Layer 4 duplication (Chapter 3) getting fixed the same way Layer 3 gets fixed: by giving one piece of knowledge one authoritative home, except now the "home" is a team and an API contract instead of a class.
This is worth sitting with because it reframes what DRY actually is. It isn't a coding-style guideline that happens to also matter at scale. It's a systems principle that applies identically whether the "system" is a function, a service, or an org chart — the only thing that changes is the size of the blast radius when the principle gets violated.
Chapter 9 — A Working Method: FAIR
Forget generic "find, analyze, act" checklists — here's a version with enough teeth to actually run in a retro.
F — Find. Don't just grep for similar code. Actively hunt for repeated facts: the same eligibility rule, the same rounding convention, the same "who wins when two discounts collide" logic, expressed differently across services. Layer 3 and 4 duplication rarely shows up in a code-similarity tool.
A — Analyze. For every duplication found, ask two questions before touching anything: is this fact likely to keep varying independently across its copies (leave it), or is it structurally the same thing wearing different clothes (candidate for unification)? Has it actually repeated three times, or are you about to abstract off a sample of two?
I — Implement. Pick the lowest tier from Chapter 5 that actually resolves the duplication — utility, workflow, behavioral, rule engine, or domain model, in that order of escalation. Reaching for DDD or a rule engine because it's the "proper" answer, when a template method would have done the job, is its own form of hasty abstraction.
R — Review. Abstractions decay. A shared strategy interface that made sense for five variants starts creaking at fifteen. A rule engine adopted for genuinely volatile rules stops being volatile and becomes dead weight nobody remembers how to operate. Schedule the recheck — don't wait for the abstraction to fail loudly.
Chapter 10 — What This Was Actually About
None of this was really about tools. Template method, AOP, Drools, DDD — those are just five different-sized wrenches for the same underlying job.
The job is this: complexity in software isn't caused by having a lot of code. It's caused by the same fact existing in more than one place and those copies quietly drifting apart while nobody's watching. The coupon bug at the start of this piece wasn't a coding mistake — it was thirty-nine correct updates and one place the truth forgot to visit.
Good engineering judgment, at senior level, mostly isn't "write elegant code." It's noticing, continuously, where a single business fact has started living in two places, and deciding — carefully, not automatically — whether that's a problem worth fixing right now or a coincidence you should leave alone until it proves itself into a pattern. That decision, repeated for years, is most of what separates a system that stays legible at scale from one that quietly becomes unmaintainable while every individual commit looked fine.
If you're starting this discipline from scratch on a real codebase, don't start with tooling. Start with a week of just finding — mapping where the same business fact gets independently expressed, without fixing anything yet. Only once you can see the actual shape of your duplication should you pick up a wrench, and even then, pick the smallest one that does the job.
If you'd like to show your appreciation, you can support me through:
✨ Patreon ✨ Ko-fi ✨ BuyMeACoffee
Every contribution, big or small, fuels my creativity and means the world to me. Thank you for being a part of this journey!