A mapper refactor is safe only after outputs are frozen. Move one version branch after those rows match. Do not extract helpers and rename fields together.
Why this cut exists
Legacy webhook mappers grow one branch per partner version. A cleanup often changes two behaviors in one diff. Reviewers then cannot name which edit broke billing.
This walkthrough freezes nine payload rows before any edit. The rows are a worked example, not live traffic. No production metric is claimed for this sample.
Contract that must not move
The public function name stays exactly map_payload here. The return mapping keeps exactly three string keys. Those keys are event_id, amount_cents, and status only.
Supported versions stay v1, v2, and v3 only. Unknown versions still return None from this function. Non-dict inputs still return None from this function.
Amount text still becomes integer cents in this gate. Status text still becomes lowercase for every v2 row. Missing required fields still return None to callers.
Worked example, not a captured repo
The functions below are written for this article. They are not extracted from a private service. Run them locally before trusting any generated draft.
Label the file as an exercise harness only. Keep partner secrets out of these fixture rows. Synthetic ids are enough for this nine-row gate.
The nine rows
Each row binds one input to one output. A later edit must satisfy every frozen row. Adding a tenth row is a new contract.
# rows.py — synthetic fixtures for an unexecuted exercise
ROWS = [
{
"name": "v1-paid",
"payload": {"ver": "v1", "id": "e1", "amt": "12.50", "st": "paid"},
"expect": {"event_id": "e1", "amount_cents": 1250, "status": "paid"},
},
{
"name": "v1-zero",
"payload": {"ver": "v1", "id": "e2", "amt": "0.00", "st": "open"},
"expect": {"event_id": "e2", "amount_cents": 0, "status": "open"},
},
{
"name": "v1-missing-id",
"payload": {"ver": "v1", "amt": "3.00", "st": "open"},
"expect": None,
},
{
"name": "v2-paid",
"payload": {
"version": "v2",
"eventId": "e3",
"amount": 12.5,
"state": "PAID",
},
"expect": {"event_id": "e3", "amount_cents": 1250, "status": "paid"},
},
{
"name": "v2-refund",
"payload": {
"version": "v2",
"eventId": "e4",
"amount": -4.0,
"state": "REFUND",
},
"expect": {"event_id": "e4", "amount_cents": -400, "status": "refund"},
},
{
"name": "v2-missing-amount",
"payload": {"version": "v2", "eventId": "e5", "state": "OPEN"},
"expect": None,
},
{
"name": "v3-cents",
"payload": {
"version": "v3",
"data": {"event_id": "e6", "cents": 990, "status": "paid"},
},
"expect": {"event_id": "e6", "amount_cents": 990, "status": "paid"},
},
{
"name": "v3-nested-miss",
"payload": {"version": "v3", "data": {}},
"expect": None,
},
{
"name": "unknown-ver",
"payload": {"version": "v9", "eventId": "e7", "amount": 1},
"expect": None,
},
]
Nine names are the whole gate for this move. Do not drop a red row to stay green. Fix the fixture or revert the failing code.
Baseline mapper before the move
Read this function as the legacy body under test. Characterize that full body before any extraction starts. A prettier rewrite counts as a second change.
# mapper_before.py — baseline used only to freeze rows
def map_payload(payload):
if not isinstance(payload, dict):
return None
ver = payload.get("ver") or payload.get("version")
if ver == "v1":
if "id" not in payload or "amt" not in payload:
return None
cents = int(round(float(payload["amt"]) * 100))
return {
"event_id": payload["id"],
"amount_cents": cents,
"status": payload.get("st", "open"),
}
if ver == "v2":
if "eventId" not in payload or "amount" not in payload:
return None
cents = int(round(float(payload["amount"]) * 100))
state = str(payload.get("state", "OPEN")).lower()
return {
"event_id": payload["eventId"],
"amount_cents": cents,
"status": state,
}
if ver == "v3":
body = payload.get("data") or {}
if "event_id" not in body or "cents" not in body:
return None
return {
"event_id": body["event_id"],
"amount_cents": int(body["cents"]),
"status": body.get("status", "open"),
}
return None
The v2 branch is the only planned move. The v1 branch stays inline for this commit. The v3 branch stays inline for this commit.
1. Freeze the baseline
Run the stdlib checker against mapper_before.py before editing. Treat every failure here as a fixture problem. Do not start the extract on a red baseline.
# gate.py — stdlib checker, unexecuted until you run it
from rows import ROWS
def check(mapper):
failed = []
for row in ROWS:
got = mapper(row["payload"])
if got != row["expect"]:
failed.append((row["name"], got, row["expect"]))
return failed
def main():
from mapper_before import map_payload
failed = check(map_payload)
print(f"rows={len(ROWS)} failed={len(failed)}")
for name, got, expect in failed:
print(f"{name} got={got!r} expect={expect!r}")
raise SystemExit(1 if failed else 0)
if __name__ == "__main__":
main()
mkdir -p refactor_gate && cd refactor_gate
python -m py_compile rows.py gate.py mapper_before.py
python gate.py
Compile the three modules before the first run. A syntax error is not a behavior failure. Fix syntax first, then freeze outputs, then extract.
The expected baseline line is exactly rows=9 failed=0. Any other count means the freeze has failed. Stop there and repair the row, not the design.
Run this checker with Python 3, not Python 2. Python 3 dict equality ignores key order. A matching mapping still passes if key order differs.
2. Score the cut before editing
Use a decision table before opening the code diff. The table picks one branch and rejects mixed cleanups. Read every fail rule before you edit code.
| Check | Pass rule | Fail rule |
|---|---|---|
| Branch chosen | Only v2 moves |
v1 or v3 also moves |
| Branch coverage | Three v2 rows exist |
Fewer than three v2 rows |
| Return keys | Same three keys | A key is renamed or added |
| Rounding call | Same round(float(...)*100)
|
The formula is replaced |
| New version | Absent from this commit |
v4 handling appears |
| Baseline |
failed=0 on nine rows |
Any red baseline row |
One failed check cancels this planned mapper move. Do not negotiate a partial pass on the table. Wait for a green baseline before any extract.
3. Extract only map_v2
Copy the v2 body into a new function. Call that function from the same version branch. Leave every other line unchanged in this commit.
def map_v2(payload):
if "eventId" not in payload or "amount" not in payload:
return None
cents = int(round(float(payload["amount"]) * 100))
state = str(payload.get("state", "OPEN")).lower()
return {
"event_id": payload["eventId"],
"amount_cents": cents,
"status": state,
}
Inside map_payload, replace the v2 block with one return. Keep the version test inside the parent function. That single call preserves the unknown-version fallthrough path.
if ver == "v2":
return map_v2(payload)
Do not format the v1 block in this commit. A whitespace-only edit is still a second move. Commit the extract alone after the suite passes.
4. Re-run the same nine rows
Point gate.py at the extracted module next. Keep the same ROWS list for the rerun. A renamed row is not the same gate.
# swap this import after the extract; leave ROWS unchanged
from mapper_after import map_payload
python gate.py
python -m pytest tests/test_map_payload.py -q --tb=line
The pytest command is optional in this exercise. The stdlib gate is the required check here. Use pytest only if the repo already depends on it.
The pass rule is a printed rows=9 failed=0 line. Revert when any non-v2 name appears in failures. That result means the cut leaked into another branch.
5. Read a red line before patching
A red line prints the row name, the got value, and the expect value. Match the name to the duty table before editing. A wrong expect value is a fixture bug.
A wrong got value after the extract is a code bug. Do not edit both sides in one commit. Restore the baseline, then freeze again, then retry one cut.
| Printed name | Likely owner | Next action |
|---|---|---|
v2-paid |
Extracted branch | Compare cents and status text |
v2-refund |
Sign handling | Keep the same round call |
v2-missing-amount |
Guard clause | Restore the missing-field return |
v1-paid or v3-cents
|
Leak outside v2
|
Revert the whole commit |
unknown-ver |
Fallthrough removed | Put the version test back |
Three v2 names may fail together after a bad extract. One non-v2 name is enough to revert. Do not patch v1 while investigating v2.
6. Inspect the diff scope
Count touched hunks by hand against the allowlist. The allowed hunks are the new map_v2 function and the v2 call site. Any hunk inside v1 or v3 fails the table.
git diff --stat -- mapper_after.py
git diff -U3 -- mapper_after.py
git show --name-only --pretty=format: HEAD
One mapper file is the preferred commit scope. A lockfile change is out of scope here. Revert unrelated files before the review starts.
7. Use a drafter only after the freeze
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode free model access can draft row names from the baseline function. MonkeyCode's free server option can run python gate.py on a clean checkout. Those two lines are availability claims from the operator.
This article states no model names, quotas, hardware, or duration. Do not infer a quota from a successful draft. Do not treat a listed model nickname as verified here.
Paste the baseline and the nine expected rows. Ask for a checker that asserts those rows. Reject a draft that rewrites map_payload before failed=0.
Run the same command locally and on the free server. Compare the two printed summaries before you merge. A mismatch means the two runtime environments differ.
Do not treat a remote pass as live traffic proof. The nine-row method does not require that product. Any editor and any clean runner can host the gate.
Remove the product and the same steps still stand. The gate file remains the method without a login. Keep that separation visible in the review notes.
Limits you should keep visible
This gate does not prove signature checks at all. It also skips replay windows and concurrent delivery. It proves these nine synthetic inputs and nothing else.
Python 3 round uses half-to-even on binary floats. Read the current round notes before changing cents. The sample value 12.5 is exact in binary.
The value 1.005 is not proven by these rows. A green run can freeze a bug on purpose. These characterization tests lock current behavior, including mistakes.
Change a cents rule only after a new failing row exists. Empty data is already covered by the v3-nested-miss row. A list payload is not covered by this gate.
Default status text is also outside the nine rows. A missing v1 st field is not pinned here. Add that row before editing the default.
Who should skip this workflow
Skip it when the mapper has no callers yet. New code needs design tests, not a freeze of accidents. Skip it when a release must change behavior now.
Write the new failing row first, then edit. That order keeps the required behavior change visible. A freeze alone cannot justify a policy change.
Skip it when payloads hold secrets or card numbers. Do not upload live bodies to a free server. Synthetic rows are the only safe fixture here.
Skip it when one commit cannot be reverted. The method needs a small reversible diff each time. A stacked cleanup removes that revert safety entirely.
Close
Freeze all nine rows on the current baseline. Extract map_v2 and leave the other branches. Re-run python gate.py and read the diff.
Keep the commit only when every table row passes. If a free model drafts the checker, compare each expected value yourself. If a free server runs the file, keep every fixture synthetic.
Top comments (1)
tr.ee/dev-to