The Function That Did Nine Things: A Refactor Diary
How a 340-line order handler became six small functions, and why the tests mattered more than the new names.
Every codebase has one. Ours was called handleOrder, it lived in a file nobody wanted to open, and it was 340 lines long. It validated input, applied coupons, checked stock, charged the card, wrote three tables, sent an email and, for reasons lost to history, also logged a metric to a dashboard we deleted in 2023.
I spent two weeks taking it apart. This is what I learned, in the order I learned it.
Day 1: Do not touch anything yet
My first instinct was to start extracting functions. My second, better instinct was to write characterisation tests first. These are not tests of what the code should do. They are tests of what it currently does, bugs included.
I wrote 23 of them. Two of them captured behaviour that was clearly wrong: a coupon could be applied twice if the request was retried within 400ms. I left the bug in place and marked the test. Fixing bugs during a refactor is how you end up unable to explain why production changed.
Day 3: Find the seams
I printed the function. Yes, on paper. Then I drew boxes around blocks that shared variables. Nine boxes appeared, and they mapped almost perfectly to the nine jobs listed above.
The trick was noticing which boxes were decisions and which were side effects. Validation, coupon logic and stock checks only decided things. Charging, writing and emailing actually did things. That split became the whole design.
Day 6: Extract, run, commit
I followed a tiny loop for every extraction:
- Move one block into a new function with an ugly but honest name.
- Run all 23 tests.
- Commit with a message describing only that move.
By the end I had 31 commits, each one boring. Boring commits are a gift to whoever runs git bisect next year.
const plan = buildOrderPlan(input, inventory, coupons)
if (!plan.ok) return reject(plan.reason)
await executeOrderPlan(plan, deps)
That is the new handler, more or less. The pure planning step is 90 lines and needs no mocks to test. The execution step is short and almost entirely I/O.
Day 10: The names came last
I renamed things only after the structure settled. Names chosen early tend to describe the code you wish you had. Names chosen late describe the code you actually have.
What it bought us
- New test cases for coupons now take minutes, not an afternoon.
- The double-coupon bug got its own clean fix, in its own PR, with its own review.
- Two engineers who had avoided that file for a year have since shipped changes to it.
A refactor is finished when someone else is willing to edit the code without asking you first.
That last point is the real metric. Line counts are just trivia.
Cover: Photo by Chris Ried on Unsplash.