Software Architecture · Technical Debt

Divergent Duplicates and the Cost of Two Truths

How two copies of one business rule drift apart without any build failing, why the drift is expensive to resolve, and how to detect divergence before deciding which copy is right.

Two functions compute a customer’s eligibility for a discount. They were written a year apart by different people, they agree today on every input anyone has tried, and they are both in production. One serves the checkout path, the other the nightly reconciliation. Nobody knows they are a pair, because they live in different packages and neither calls the other.

This is a different problem from ordinary duplication. Ordinary duplication is visible, mechanically removable, and usually cheap: extract the common body, delete one copy, run the tests. Divergent duplicates have already escaped that remedy, because the two copies no longer compute the same function. There is no merge to perform. There is only a decision about which behaviour is correct, and that decision usually belongs to someone outside engineering.

How one rule becomes two

The split almost never starts as a copy-paste. It starts as a single correct implementation that two callers need in slightly different circumstances, and one caller cannot use it directly — wrong package, wrong layer, wrong release cycle. So a second implementation is written against the same requirement, by someone reading the first one or the specification behind it. At that moment the two are equivalent.

They diverge because changes arrive at one site at a time. A legislative change adjusts a threshold; the engineer changing checkout updates the checkout implementation and does not know the reconciliation one exists. A performance fix replaces a lookup with a precomputed table in one path only. A bug report is fixed where the bug was observed. Each edit is locally correct and reviewed as a self-contained change. Nothing in the toolchain compares the two, so nothing fails.

The drift is not random. It follows the incentive gradient of whoever maintains each site: the checkout copy tracks checkout requirements, the reconciliation copy tracks accounting requirements, and the difference between those requirements accumulates. Ten small decisions later, the two functions differ on a set of inputs nobody has enumerated, and there is no longer a single rule — there are two rules that share a name and a purpose.

Why no test catches it

A test suite validates each implementation against the expectations written for it. If the checkout tests were written from checkout’s behaviour and the reconciliation tests from reconciliation’s behaviour, both suites pass indefinitely while the outputs disagree. Test suites encode the current behaviour of the thing they test; they do not compare two things unless someone deliberately writes that comparison.

The failures that result are therefore found by downstream effects. A customer is charged one amount and reconciled against another, and the difference is discovered during an audit or a complaint. Because the discrepancy is small and input-dependent, it survives sampling: most inputs agree, so spot checks pass. The probability that a random input lands in the divergent region can be very low while the region itself contains the inputs that matter, because divergence concentrates around boundary cases — threshold values, zero quantities, currencies with different minor-unit exponents, dates at period ends.

There is a second failure mode that is worse than a wrong answer: the organisation loses the ability to say what the rule is. When asked, three engineers give three answers, each accurate about the code they maintain. Any new implementation now has no authority to conform to, so it becomes a third copy.

The arithmetic that makes duplicates disagree

Divergence often lives in operation order rather than in the formula, which is why reading the code does not reveal it. Tax computed per line and then summed is not the same as tax computed on the invoice total, even at one rate, because rounding happens at different granularities.

Take three lines of 3.33, 3.33, and 3.34 in the same currency, with a rate of 7.25 percent and half-up rounding to the minor unit. Per line: 3.33 × 0.0725 = 0.241425, rounds to 0.24; twice more for the other lines gives 0.24 and 0.24215 → 0.24. The summed tax is 0.72. On the total: 10.00 × 0.0725 = 0.725, which rounds to 0.73. One minor unit of difference from an ordering choice, and the choice is invisible in either implementation’s tests unless the tests use multi-line invoices.

The same class of divergence appears wherever a calculation chain has more than one rounding point: currency conversion then rounding versus rounding then conversion; percentage discounts applied sequentially versus combined; interest accrual per day versus per period. In each case both implementations are defensible and a specification must pick one. When two implementations pick differently, the difference is deterministic and reproducible but only on inputs that exercise the boundary — exactly the inputs that a test suite built from typical cases omits.

from decimal import Decimal, ROUND_HALF_UP

def tax_per_line(amounts, rate):
    return sum((a * rate).quantize(Decimal("0.01"), ROUND_HALF_UP) for a in amounts)

def tax_on_total(amounts, rate):
    return (sum(amounts) * rate).quantize(Decimal("0.01"), ROUND_HALF_UP)

lines = [Decimal("3.33"), Decimal("3.33"), Decimal("3.34")]
rate = Decimal("0.0725")
print(tax_per_line(lines, rate), tax_on_total(lines, rate))  # 0.72 0.73

Detection before consolidation

The prerequisite for fixing divergence is knowing where it exists, and the reliable method is differential comparison rather than code reading. The mechanics are worth spelling out because they determine whether the comparison is meaningful.

Generate inputs from the domain rather than from examples: boundary values at every threshold, zero and negative quantities, maximum representable amounts, and combinations across the dimensions the rule reads. Property-based generation is well suited because it explores combinations a human would not enumerate. Then run both implementations on the same inputs and compare outputs, not internal state. Where the outputs differ, the differing input is a specification question, not a bug report.

When implementations cannot be executed side by side — one is a mobile client shipped months ago, one is a server — the comparison has to be reconstructed from recorded traffic. Replaying historical requests through both versions and diffing the responses gives the same information, at the cost of needing the old version to still run.

A lighter-weight signal comes from version history: two files that are edited in the same change request repeatedly are candidates for a hidden pair, whether or not the shared rule is visible in their code. The check is cheap and points at the files worth reading, but it cannot distinguish a real duplicate from a convention that happens to require touching both.

Once a divergent pair is found, the interesting part begins. The decision is not “which implementation is right” but “what is the rule”, and the answer has to be established with whoever owns the policy. Only then is a single implementation possible, and only then does consolidation stop being a coin flip.

When two implementations are legitimately correct

Some duplication cannot be removed, and pretending otherwise produces worse designs. Three cases recur.

Trust boundaries. Client-side validation exists to avoid a round trip; server-side validation exists because the client cannot be trusted. Both implement the same rule and both must, and they will drift because they are in different languages and different release cycles. The resolution is not to share code but to make the server authoritative: the client copy is an optimisation whose disagreement is corrected by the server’s answer, and the design must be able to accept that correction. A form that displays one total and charges another has failed at this, and the failure is architectural rather than arithmetic.

Different non-functional envelopes. A batch path can afford a table scan that an interactive path cannot, so the two are implemented differently on purpose. Sharing the code forces one of them into the wrong performance class. Here the shared artifact should be the specification and the test vectors, not the function.

Independent failure domains. Two services implementing the same rule so that neither depends on the other’s availability is a deliberate design choice. The price is exactly the divergence problem, and the mitigation is a conformance suite both must pass, run against each on release.

In all three cases the duplication is a decision with a known cost, which is the difference between debt and accident. The cost is that the rule now has multiple encodings that must be kept aligned by process rather than by the compiler.

The shared artifact need not be code

The most useful reframing is that consistency between two implementations requires a shared definition, and a definition can be data. A table of canonical input-output vectors, checked into the repository and executed by both implementations’ test suites, makes divergence a failing build rather than an audit finding. The vectors do not need to be exhaustive; they need to cover the boundaries where ordering choices show up, because that is where the implementations will disagree.

This is the same technique that keeps a reimplementation honest against an old system during a migration, and it is cheap enough to apply to any pair that is expected to agree. It also forces the specification question to be answered in a concrete form: writing the vector for a threshold case requires deciding what happens at the threshold.

Duplicates raise the cost of every edit to the shared rule, in the way described in Change Amplification: How Coupling Sets the Price of an Edit. And when the duplicated rule is a data shape rather than a function, the divergence ends up stored in records that outlive the code, which is the subject of Data Shape Debt and the Migration That Outlives the Code.

References

  1. Refactoring: Improving the Design of Existing Code — Martin Fowler, 2nd edition, Addison-Wesley, 2018
  2. Working Effectively with Legacy Code — Michael Feathers, Prentice Hall, 2004
  3. IEEE 754-2019: Standard for Floating-Point Arithmetic — IEEE
  4. Designing Data-Intensive Applications — Martin Kleppmann, O'Reilly, 2017