Adding millimetres to an inch-based pricing engine without touching a single stored number
Every pricing rule in a quoting system for a fabrication business was written in inches. Six hundred and ten calculation steps, all of them dividing by 144 to get square feet, all of them assuming the number they were handed was an inch measurement.
The business’s own quotation documents print dimensions in millimetres, and their estimators increasingly work in millimetres. They asked to enter millimetres.
There are three ways to do that and two of them destroy the existing quotes.
The two that destroy things
The first is to convert the rules. Rewrite the expressions so they take millimetres. This means editing 610 steps authored by the business, changing the constants, and re-verifying every one of them. It also means every quote already in the system was priced by a rule that no longer exists, so nothing historic can be recalculated or explained. Rejected immediately.
The second is subtler and more tempting: store whichever unit the user typed, and record which unit it was. A dimension row gains a unit column. The engine reads the unit and converts before evaluating.
This one looks reasonable and it is a slow-motion failure. Now every piece of code that touches a dimension has to know about units — the calculation, the save path, the seed defaults, the export, the preview, the duplicate-a-line control, the recalculation on revision. Miss one and you have a number that is 25.4 times wrong in one place and correct everywhere else. The cost of the design is paid forever, by everybody, including people who join later and do not know the rule.
The third one
Keep one internal unit and convert at the boundary. Dimensions are inches inside the system, always, with no exceptions and no unit column on the stored value. The unit the user chose is a property of the quotation, and it does the only thing it needs to: it converts inbound values on the way in through the API, and outbound values on the way out.
Below that line nothing knows units exist. The engine, the storage, the recalculation, the duplicate control, all of them see inches, as they always have.
The property that made this safe to ship was that for a quotation in inches, the conversion is the identity function. Not approximately, not within rounding — the same number goes in and comes out. Every existing quotation was in inches, so every existing quotation was byte-for-byte unchanged by the deployment. That is not a nice-to-have; it is what let this go onto a live system holding real customer quotes without a data migration and without a reconciliation afterwards.
If your unit change cannot be expressed as an identity for the data you already have, you are doing a migration, and you should call it one.
The part I got wrong
Boundary conversion has one hard requirement: you have to find all the boundaries.
I found the obvious ones — the calculate endpoint, the save endpoint, the seeding of default dimensions when a line is created. I missed one. There is a secondary editing surface where dimensions can be adjusted outside the main wizard step, and it wrote through a path I had not threaded the conversion into. A quote in millimetres, edited there, would have stored millimetres as if they were inches, and priced the item at roughly one twenty-fifth of its value.
It was caught before it shipped, and it was caught for a boring reason: the change was tested against a restored copy of the production data on a local stack before being pushed, rather than being tested after deployment. Working against real records with real dimensions is what made a wrong number visibly wrong. Against fresh test data, 45 and 1143 are both just numbers.
The general point is that “convert at the boundary” is only as good as your inventory of boundaries, and the inventory is not obvious from reading the code. Every write path that can set a dimension is a boundary, including the ones added for convenience later.
Round-tripping is the test to write
The end-to-end test that guards this does two things and only two things.
It seeds a line in a millimetre quotation with a value that is a clean inch figure — a thousand millimetres, which is 39.37 inches — and asserts the interface shows a thousand. Then it saves two thousand millimetres and asserts the value comes back as the correct inch figure through the other path.
A round trip catches the class of bug that matters here, which is a conversion applied twice, or applied on the way in but not the way out, or applied in one write path and not another. It catches it in a way that a unit test on the conversion function cannot, because the conversion function was never the thing that was wrong.
The related decision that had nothing to do with arithmetic
The same request carried a second item that looked cosmetic and was not. Units of measure on the materials themselves — the pieces, kilograms, running feet a stock item is priced in — were free text. Anyone could type anything, and over time the same unit existed under several spellings.
That became a fixed list. Not because free text is untidy, but because a unit of measure that can be spelled three ways is a unit of measure you cannot group, filter or reconcile on, and every report that touches it silently splits into three.
There is a pattern in both halves of this work. A unit is a fact about the boundary of the system, not a fact about a stored number. Convert at the edge, constrain the vocabulary at the edge, and keep the interior in one dialect.
The limit
One surface deliberately stayed on inches: the parametric preview that draws the item. Its geometry, its layout constants and its part sizes are all inch-based, and it reads the internal values directly. That means a millimetre-working customer sees a preview whose internal coordinate system is not the one they think in.
It has caused no complaint, because nobody reads coordinates off a preview. It is still an inconsistency, and the honest description is that it survives because the display never exposes the numbers rather than because it was solved.
The rule
Pick one internal unit and make the boundary the only place a conversion happens. Verify that the conversion is exactly the identity for whatever unit your existing data is in, so shipping it changes nothing already stored. Then enumerate every write path that can set the value — not just the ones you designed — because that inventory, not the arithmetic, is where this goes wrong.