Case study · Inheriting a system that already has customers
Modernizing Storefront While the Pilot Ships
Most leads do not get a clean sheet. They get a nine-year-old system with real customers, undocumented consumers, two recent incidents, thirty percent test coverage — and a product deadline that does not move. This is what the work actually looks like, decision by decision.
Mission tie-in: the capstone shows a well-run initiative on a system that mostly cooperates. This case shows the same judgment applied where the system does not. Nothing here is a rewrite; every move is a small, reversible step taken while the business keeps trading.
The situation on day one
You have just taken technical lead of the returns-resolution initiative — the same initiative framed in lesson 0039 and worked end to end in the capstone packet. The outcome is unchanged: median resolution for eligible domestic returns from 72 hours to under 4 hours, support-contact rate down 25% from 41%, fraud loss no worse, manual review queue at or below 50 cases per day, WCAG 2.2 AA on critical flows, pilot by 30 September. Today is Monday 4 August. That is eight weeks.
What is new is the system you must do it in.
| Fact | Number | Why it constrains the design |
|---|---|---|
| Age and shape | First commit March 2017; 312,000 lines of Python; one Django deployable; releases Tuesday and Thursday | One deploy unit means one blast radius. Every returns change ships with everything else. |
| Database | PostgreSQL 12, one primary and one read replica, 217 tables in public | No schema documentation. The schema is the integration contract, whether anyone signed it or not. |
| The blob | orders.return_json (jsonb), non-null on 3.1M of 41.2M orders rows; 11 distinct key-shapes accumulated since 2017; largest value 84 KB | Return state has no schema, no constraints, no history and no index. Every rule reads it defensively. |
| Load | 340 rps median, 1,150 rps peak; returns endpoints 40 rps at peak; partner API 4.2 rps | 40 rps at 800 ms p95 is the performance driver from lesson 0043. It is not large. Availability, not throughput, is the pressure. |
| Tests | 31% line coverage overall; 12% under storefront/returns/; assess_return() is one 900-line function | You cannot refactor what you cannot verify. Coverage is the first thing to buy, not the last. |
| Team | 6 engineers (4 backend, 1 frontend, 1 platform at 50%), 1 designer, 1 PM | Six engineers cannot run two systems. That single fact decides most of section nine. |
| On-call | Shared 6-person Storefront rotation with the checkout team; one week in three | Anything you add is carried at 3 a.m. by someone who did not build it. |
Three groups read the database directly, and none of them asked permission — because until now nobody said they needed to.
| Consumer | What it reads | Change cost |
|---|---|---|
| Finance reporting (Metabase) | 34 saved queries over orders, order_lines; 6 of them parse return_json with ->>; 2 month-end reconciliations | One analyst, Dan. Rewriting the 6 blob queries is roughly two days — but only after something stable exists to point them at. |
Warehouse app (wms) | Separate deployable, separate team; reads orders/order_lines, writes order_shipments; 3 queries join return_json | A different release train. A column rename is a cross-team coordination event, not a migration. |
| Marketing ETL (Airflow) | Nightly SELECT * over 6 tables at 02:10; the orders extract holds ACCESS SHARE for about 6 minutes | Nobody owns it. It is also the reader that queued in front of INC-2503. |
Outside the company there is a fourth consumer you cannot deploy with. The partner API — GET|POST /api/v2/orders/{order_id}/returns — serves 63 marketplace sellers. The top three are 61% of partner volume; nine sellers have not deployed a client in two years. There is no versioning policy, no deprecation window, and no record of which fields anyone actually reads. "We will tell the partners" is not available to you. Whatever you do to returns must leave that payload byte-compatible.
And there are the two incidents that are still open in everyone's memory.
INC-2481 · 14 July · duplicate refunds during a retry storm
The payments platform team's POST /refunds (v2) accepts no idempotency key. A nine-second latency spike at the gateway pushed Storefront's Celery task past its timeout; the task retried three times with the same intent. 214 duplicate refunds, EUR 38,400, discovered three days later by finance's month-end reconciliation — not by an alert. The proximate cause was a retry. The systemic condition was a non-idempotent call with no local record of intent.
INC-2503 · 5 August · 40-minute outage from a migration lock
Tuesday's release included ALTER TABLE orders ADD CONSTRAINT ... NOT VALID — cheap DDL, but it requires ACCESS EXCLUSIVE. It landed at 14:07 while a finance export had been holding ACCESS SHARE on orders for six minutes. The ALTER joined the lock queue; every query that arrived afterwards queued behind it. Checkout returned 5xx for 40 minutes (10 to notice, 25 to find the lock, 5 to cancel), 11,400 failed checkouts. The migration itself was correct. The lock discipline around it did not exist.
Finally, the constraint you did not choose: the payments dependency belongs to another team. Four engineers, their own roadmap. Their refund-service v3 — with per-request idempotency keys — is committed for 14 November. That is six weeks after your pilot. You will not get it moved, and you should not spend your credibility trying.
"Returns is a mess. Let's extract a Returns Service. Eight weeks: two to design the schema properly, three to build it on a clean Postgres with an event stream from orders, one to migrate the data, one to cut the partner API over to v3, one to stabilize. We freeze returns changes in the monolith while we build, then flip."
It is seductive because every sentence is individually reasonable, and because the blob genuinely is a mess. It fails on this system for six specific reasons, none of them aesthetic:
- There is no verification of current behavior. At 12% coverage in the returns package, "the new service does what the old one did" is an assertion nobody can check. The 900-line
assess_return()encodes nine years of policy exceptions that exist in no document. A rewrite ships the exceptions you happened to remember. - The freeze is a lie you tell for eight weeks. Returns bugs will arrive during the freeze — they arrive every week now — and they will be fixed in the monolith, forking the two implementations from day three.
- "Flip" is one irreversible step. Lesson 0037's classification is unambiguous: refunds moving through untested code, at once, for all customers, is the irreversible class shipped at reversible speed. That is exactly the shape of INC-2481.
- It ignores three consumers it cannot see. Finance, the warehouse app and the ETL do not read your service. They read the table. A new store does not remove them; it strands them.
- It ignores 63 partners who cannot deploy with you. "Cut the partner API over to v3" is one week of your calendar and an unknown number of months of theirs — including nine sellers who will simply break.
- It produces no product evidence until week eight. The initiative's job is to move the resolution-time outcome. This plan learns nothing about that outcome until it is too late to change anything, which is the failure lesson 0047 names precisely: a plan that arranges component work instead of retiring uncertainty.
The rewrite is not rejected because rewrites are always wrong. It is rejected because on this system, at this coverage, with these consumers and this deadline, it buys neither stability nor speed — the trap lesson 0035 names. Everything below is what the team did instead.
Decision 1 · Pin the behavior before touching it
The first week buys verification, not architecture. A characterization test does not assert what the system should do; it records what it does, so that any later change to that record is a decision someone made rather than a surprise someone shipped. Feathers' framing in Working Effectively with Legacy Code (ch. 13) is the one to hold: these tests are not a quality statement, they are a vise.
Which behavior to pin first is a risk question, not a coverage question. The team ranked candidates by what breaks silently and expensively:
| Candidate behavior | Failure if it changes silently | Pinned in week 1? |
|---|---|---|
| The eligibility decision and its refusal reason for a sampled 2,000 orders | Customers refused who used to be approved, or refunded who used to be refused. Money and trust, discovered by support. | Yes — golden master, stratified by reason code |
| The exact v2 partner payload for the 40 most recent calls per top-3 seller | Nine sellers with two-year-old clients break, and you find out through their support channel. | Yes — recorded response fixtures |
| Refund amount arithmetic (partial returns, restocking fee, shipping, tax) | Cent-level drift across 3.1M historical returns; finance finds it at month end, as with INC-2481. | Yes — table-driven examples from real orders |
| The 11 blob shapes and how each is coerced on read | The oldest shapes are the rarest, so the tests you would have written by hand miss them. | Yes — one fixture per shape, mined from production |
| Admin-screen sort order, email copy, label PDF layout | Cosmetic. A revert fixes it. | No — reversible class, not worth the week |
The golden master is the technique that makes 900 untestable lines tractable: run the real function over a fixed sample of real inputs, record a small stable projection of the output, and assert byte-equality forever after. Projecting matters — recording the whole result would fail on every timestamp and make the test noise rather than a vise.
# tests/characterization/test_return_decisions.py
# Pins what Storefront DOES today. Not what the policy document says.
import json
from pathlib import Path
from storefront.returns.legacy import assess_return # the 900-line function
GOLDEN = Path(__file__).parent / "golden" / "return_decisions.json"
def fingerprint(order_id: str) -> dict:
"""A small, stable projection of a large, unstable result."""
result = assess_return(order_id)
return {
"order_id": order_id,
"decision": result["decision"], # approved | refused | manual
"reason_code": result.get("reason_code"),
"refund_cents": result.get("refund_cents"),
"label_count": len(result.get("labels", [])),
}
def test_return_decisions_are_unchanged(frozen_clock, seeded_db):
recorded = json.loads(GOLDEN.read_text())
actual = [fingerprint(row["order_id"]) for row in recorded]
assert actual == recorded # a diff is a behavior change, not a bug fix
def rerecord() -> None:
"""Run deliberately. The diff is reviewed in the pull request. Never in CI."""
order_ids = [row["order_id"] for row in json.loads(GOLDEN.read_text())]
GOLDEN.write_text(json.dumps([fingerprint(o) for o in order_ids], indent=2))
Three details make the difference between a vise and a nuisance. The sample is stratified: 2,000 orders drawn so that every reason code and every one of the 11 blob shapes appears at least 20 times, rather than 2,000 orders from last Tuesday that are all the same shape. The clock and the database are injected — a golden master that depends on now() re-records itself daily and pins nothing. Re-recording is a reviewed act: the rerecord() function exists so that intentional behavior changes appear as a readable diff in a pull request, with a human writing down why.
† Coverage went from 12% to 58% in the returns package in six days — and the team wrote almost no assertions about correctness. That is the point. Characterization tests are not the tests you keep forever; several were deleted in week six once the module's own example-based tests covered the same rules with intent. They are scaffolding that lets you move at all, and lesson 0037's claim — tests are the speed technology — is measurable here: the median returns pull request went from four days in review to under one.
Decision 2 · Find the seams, then protect behavior through them
Feathers' seam is the load-bearing idea of the whole case: a place where you can alter behavior without editing in that place. Every seam has an enabling point — somewhere you decide which behavior runs. In Python the useful ones are almost always object seams: a parameter with a default, a protocol, a factory. The legacy view below has none, which is why nothing about it can be tested.
# Before: no seam. The rule is welded to the request, the clock and the ORM.
class LegacyReturnView(View):
def post(self, request, order_id):
order = Order.objects.get(pk=order_id)
blob = order.return_json or {}
if (timezone.now() - order.delivered_at).days <= 30 and not blob:
blob = {"state": "requested", "opened": request.POST.get("opened")}
order.return_json = blob
order.save(update_fields=["return_json"])
return render(request, "returns/requested.html", {"order": order})
Two moves get new behavior into that method without a refactor you cannot verify.
Sprout. Write the new rule outside the legacy code, test it properly, and call it from a single line. Nothing existing is restructured; the risk of the change is the risk of one added call. This is the move for new behavior.
# Sprout: the new rule is born outside — tested, pure, and called from one line.
def classify_request(delivered_at, now, opened: bool) -> str:
"""New code. No Django, no database, no clock of its own."""
if opened:
return "manual_review"
return "eligible" if (now - delivered_at).days <= 30 else "outside_window"
class LegacyReturnView(View):
def post(self, request, order_id):
order = Order.objects.get(pk=order_id)
blob = order.return_json or {}
blob["classification"] = classify_request( # the sprout, one line
order.delivered_at,
timezone.now(),
opened=bool(request.POST.get("opened")),
)
order.return_json = blob
order.save(update_fields=["return_json"])
return render(request, "returns/requested.html", {"order": order})
Wrap. Rename the existing function, give the old name to a new one that calls it, and put the new behavior around the outside. This is the move when you need something to happen on every existing call — which is exactly the shape of INC-2481's fix. The team could not make the payments team's endpoint idempotent, so they made their own intent idempotent, at the one seam every refund passes through.
# Wrap: the old body is untouched; the new behavior surrounds every call site.
def issue_refund(return_id: str, amount_cents: int) -> str:
"""The seam INC-2481 needed: a local record of intent, before the call."""
claim, created = RefundClaim.objects.get_or_create( # unique on return_id
return_id=return_id, defaults={"amount_cents": amount_cents}
)
if not created:
if claim.payments_reference is None:
raise RefundInFlight(return_id) # a human decides; never retry blind
return claim.payments_reference # the retry storm stops here
claim.payments_reference = _issue_refund_legacy(return_id, amount_cents)
claim.save(update_fields=["payments_reference"])
return claim.payments_reference
Note what the wrap does not do: it does not retry, and it does not guess. A claim row with no reference means the call may or may not have reached the gateway, and the honest response is to stop and surface it — the fail-fast rule from lesson 0014, applied to money. The number of in-flight claims became an alert with a threshold of one, and a daily reconciliation report against the payments team's ledger became the compensating control for a dependency the team does not own.
| What you need | Move | What it costs |
|---|---|---|
| New behavior in an untestable method | Sprout a tested function; call it once | The legacy method stays ugly. Accept it — you are not paid to make it pretty this week. |
| Something to happen on every existing call | Wrap: rename the old body, take its name | One extra indirection, and a name that now means two things until the old body dies. |
| To swap an implementation behind 40 call sites | Object seam + branch by abstraction | An interface that must be right enough to hold both implementations. |
| To change what a whole request path does | Routing seam at the edge (next section) | Two live code paths, and the discipline to delete one. |
| To make a global dependency testable at all | Parameter with a default (clock=timezone.now) | Nothing, and it is usually a two-line change. Do it first. |
Decision 3 · Strangler fig or branch by abstraction?
These are often used as synonyms and they are not. Choosing wrongly costs weeks.
Branch by abstraction
Insert an interface over a component inside one deployable, migrate every caller to it, then swap the implementation behind it. The unit of progress is a caller. Use it when the old and new implementations serve the same in-process callers and you control all of them.
Here: the returns eligibility and persistence logic, called from the web view, the partner API serializer, two Celery tasks and the admin.
Strangler fig
Put a routing facade in front and send slices of traffic to the new implementation, shrinking the old one slice by slice. The unit of progress is a cohort or a route. Use it when you must move real users gradually and observe them, or when the old implementation cannot be called from the new one.
Here: which customers' return requests are served by the new module, and in what order.
The team used both, in that order, and the order is the insight. Branch by abstraction creates the seam; the strangler routes traffic across it. Doing the strangler first gives you two divergent implementations with no shared contract; doing only branch by abstraction gives you a big-bang swap wearing an interface.
# The abstraction, created first. Both implementations satisfy it.
from typing import Protocol
class ReturnsBackend(Protocol):
"""The seam. Every caller in the monolith goes through this shape."""
def assess(self, order_id: str) -> "ReturnDecision": ...
def record_request(self, order_id: str, decision: "ReturnDecision") -> str: ...
def to_partner_v2(self, order_id: str) -> dict: ...
class BlobBackend:
"""Reads and writes orders.return_json. Nine years of accumulated shapes."""
class ModuleBackend:
"""Reads and writes returns / return_lines / return_events."""
# The routing rule, added second. One function, one decision, logged once.
def backend_for(order, flags, log) -> ReturnsBackend:
reason = "legacy_default"
backend = BlobBackend()
if order.channel == "partner_api":
reason = "partner_never_routes" # 63 sellers, no atomic deploy
elif not order.is_domestic or order.is_guest:
reason = "outside_pilot_slice" # non-goals from the frame
elif flags.enabled("returns_module", key=order.customer_id):
backend, reason = ModuleBackend(), "cohort_enabled"
log.info("returns.route", order_id=order.id, backend=type(backend).__name__,
reason=reason, correlation_id=order.correlation_id)
return backend
Four rules govern that router, and each was written down because breaking it has a name:
- The default is always the system that works. Every branch that is not an explicit "yes" returns
BlobBackend. A flag service outage must degrade to the nine-year-old code path, not to an exception. - Route by cohort, not by endpoint. The flag key is
customer_id, hashed — so a customer who starts a return in the new module finishes it there. Routing per request would leave returns half-written in two places, which is the failure mode that makes people distrust strangler migrations. - Partner traffic never routes. Until the compatibility evidence in section six exists, partner-originated orders are pinned to the legacy path regardless of the flag. One line, and it removes the whole class of "we broke a seller" risk from the first five weeks.
- The routing decision is observable. Every request logs which backend served it and why, under the same correlation id as the rest of the request. Without that line, a p95 regression is unattributable and someone will roll back the wrong thing.
† The machinery has a removal date. Lesson 0035's honesty clause applies: returns_module is a release flag, not a product setting. It is in the debt ledger with an owner and a date — 31 October, two weeks after 100% — and the BlobBackend class is deleted in the same pull request that removes it. A strangler that never finishes is just two systems.
Decision 4 · Expand and contract the blob, one deployable step at a time
Splitting orders.return_json into returns, return_lines and return_events is the riskiest thing in the eight weeks: 3.1M rows, three unseen consumers, and a table that checkout writes to 340 times a second. Expand/contract (Fowler and Sato call it parallel change) is the pattern; the discipline is that every row below is a separate deploy that can stand on its own indefinitely.
| # | Step | Reverse / compensation | Evidence required before the next step |
|---|---|---|---|
| 0 | Lock discipline first. Every migration sets lock_timeout; CI rejects DDL that takes ACCESS EXCLUSIVE without it; indexes use CREATE INDEX CONCURRENTLY; constraints are added NOT VALID then validated separately. | Revert the deploy. No data touched. | A rehearsal against a restored snapshot with a deliberate six-minute reader holding ACCESS SHARE: the migration fails in 2 seconds instead of queuing. |
| 1 | Expand. Create the three tables and their indexes. Nothing reads or writes them. | DROP TABLE — they are empty. | Migration runs on the primary in under 10 seconds; replica lag unchanged. |
| 2 | Dual write, new rows only, behind flag returns.dual_write. The blob stays authoritative; the new tables are written in the same transaction and read by nobody. | Flag off. Nothing read them, so nothing to compensate. | Write-path p95 unchanged within 5 ms; zero write errors over 48 hours. |
| 3 | Backfill 3.1M historical blobs in 5,000-row batches, idempotent by order_id, resumable from a cursor table, throttled to keep replica lag under 2 s. Runs across three nights, about 4 hours total. | DELETE FROM returns WHERE backfilled_at IS NOT NULL. The blob was never modified. | Every batch reconciles; the 11 legacy shapes each produce rows; 412 rows land in a backfill_quarantine table for manual reading. |
| 4 | Verify. Drift check every 10 minutes over the last 24 hours of writes, plus one full comparison after the backfill completes. | Not a deploy. Nothing to reverse. | Seven consecutive days of zero drift, and 100% of backfilled rows matching. This is the gate that stops step 5. |
| 5 | Switch reads behind returns_module, per cohort: staff → 1% → 10% → 50% → 100%. Dual write continues throughout. | Flag off; reads return to the blob, which is still current because the writes never stopped. | Each stage holds its gate from the rollout contract: p95 within 800 ms, refusal-reason distribution unchanged, zero duplicate refunds. |
| 6 | Partner API reads the new tables, still serializing the v2 shape. | Route the serializer back to BlobBackend — one flag. | The replay test in section six is byte-identical over 4,100 recorded responses. |
| 7 | Move the other consumers. Publish a returns_v1 view over the new tables; finance's 6 blob queries and the warehouse's 3 move to it. | The view is additive; the blob is untouched. Revert is "keep using the blob". | Dan signs off that month-end reconciliation produces identical totals for two consecutive months. |
| 8 | Contract — deliberately after the pilot (Q4). Stop writing return_json; hold a 90-day read-only retention window; then ALTER TABLE orders DROP COLUMN return_json. | Irreversible. Compensation: a full column export to object storage with a rehearsed restore path, plus the 90-day window during which any missed consumer surfaces as a read of a stale value. | 90 days of zero reads of the column, proven by pg_stat_statements — not by asking around. |
The lock-avoiding tactic, in full. INC-2503 was not caused by expensive DDL; it was caused by an ACCESS EXCLUSIVE request joining a queue behind a slow reader, after which PostgreSQL made every subsequent reader queue behind it (see the explicit-locking documentation). The fix is to make the lock request give up rather than queue.
# Django migration: the lock is the risk, not the DDL.
from django.db import migrations
class Migration(migrations.Migration):
atomic = False # CREATE INDEX CONCURRENTLY cannot run inside a transaction
operations = [
# Never wait in the lock queue. Fail in 2s and let the deploy retry.
migrations.RunSQL("SET lock_timeout = '2s'",
reverse_sql=migrations.RunSQL.noop),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS returns_order_id_idx "
"ON returns (order_id)",
reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS returns_order_id_idx",
),
]
Four rules, now enforced by a CI check rather than by a reviewer's memory: set lock_timeout on every migration; build indexes CONCURRENTLY; add constraints NOT VALID and VALIDATE them in a later deploy (validation takes only SHARE UPDATE EXCLUSIVE); and never ship DDL in the same release as the code that depends on it. The last rule is what makes every row of the table above independently revertible.
The dual-write window. Between step 2 and step 8 — about fourteen weeks, ten of them after the pilot — both forms are written in one transaction and the blob remains the source of truth until step 5 moves the reads. That window is the entire safety mechanism: at any moment before step 8, turning one flag off returns the system to a state that has been continuously exercised in production. It is also a real cost, which is why it is the first line of the debt ledger rather than an unspoken assumption.
The verification query. A dual write you do not check is a dual write that has already drifted. Every ten minutes, the team compares the two forms for every return touched in the last 24 hours: join orders to returns on order_id for rows whose return_json is non-null and whose updated_at is within the day, and select the pairs where the blob's state differs from the row's state, or the blob's refund_cents differs from the row's — using IS DISTINCT FROM so that a null on one side counts as a difference rather than silently matching. The threshold is not a percentage. One row is a bug, and the read switch does not advance while any row is returned.
# Runs every 10 minutes for the whole dual-write window.
DRIFT_SQL = """
SELECT o.id AS order_id,
o.return_json ->> 'state' AS blob_state,
r.state AS row_state,
(o.return_json ->> 'refund_cents')::bigint AS blob_refund,
r.refund_cents AS row_refund
FROM orders o
JOIN returns r ON r.order_id = o.id
WHERE o.return_json IS NOT NULL
AND o.updated_at > now() - interval '24 hours'
AND (o.return_json ->> 'state' IS DISTINCT FROM r.state
OR (o.return_json ->> 'refund_cents')::bigint
IS DISTINCT FROM r.refund_cents)
LIMIT 500
"""
def check_drift(cursor, alert) -> int:
cursor.execute(DRIFT_SQL)
rows = cursor.fetchall()
if rows: # one row is a bug; the read switch does not move
alert("returns.dual_write.drift", count=len(rows), sample=rows[:5])
return len(rows)
It found three real defects in week four: a partial-refund path that rounded differently in the two writers, an admin bulk action that wrote the blob directly and bypassed the seam entirely, and a Celery task that wrote outside the transaction so a rollback left the row behind. All three would have shipped invisibly under a big-bang cutover — and the third is precisely the class of bug that makes teams conclude, wrongly, that incremental migration "does not work".
Decision 5 · Compatibility contracts for an API you cannot change atomically
The partner API is the constraint that most cleanly separates a brownfield lead from a greenfield one. You have 63 consumers, nine of whom will not deploy anything this year, and no leverage over any of them. Lesson 0033's question applies directly: you cannot pay in coupling here, because a coupled deploy across 63 organizations does not exist. You will pay in coordination — and coordination only exists if someone writes it down.
The team's first act was archaeology, not design: replay a week of partner traffic and record which fields are actually read. They could not observe partner code, so they used the next-best evidence — the fields their own v2 serializer emits, and the fields the three largest sellers send back on write. Everything else is presumed load-bearing, because absence of evidence about a two-year-old client is not evidence of absence.
| Question | The answer this team commits to |
|---|---|
| What may we add? | New optional fields on responses, and new optional fields accepted on requests. Nothing else. |
| What may we never do in v2? | Remove a field, rename a field, change a field's type or units, change the meaning of an existing value, tighten validation, or add a required request field. |
| Enum values | status is a closed set in v2 — requested, approved, refused, completed. The module's richer internal states map onto those four. New states appear only in v3. |
| Compatibility window | N−1 supported for 12 months after the successor ships; 90 days' written notice before any deprecation takes effect. |
| What we ask of partners | Read tolerantly: require only the fields you use, ignore fields you do not recognize, and do not fail on an unexpected key. Published in the partner docs, with an example. |
| How the promise is enforced | A replay test over 4,100 recorded responses runs in CI. Breaking the contract fails the build, not production. |
| Owner | Sam (partner API), with the returns lead as the second signature on any v2 change. |
That last row is the one that turns a document into a contract. The rules are executable:
# The test that lets returns move without telling 63 sellers.
def assert_v2_compatible(legacy: dict, module: dict) -> None:
"""Every key the legacy response had, with the same value. New keys allowed."""
missing = sorted(set(legacy) - set(module))
assert not missing, f"v2 lost fields: {missing}"
changed = sorted(k for k in legacy if legacy[k] != module[k])
assert not changed, f"v2 changed the meaning of: {changed}"
def test_partner_payload_is_unchanged(recorded_partner_calls):
for call in recorded_partner_calls: # 4,100 responses replayed from July
legacy = BlobBackend().to_partner_v2(call.order_id)
module = ModuleBackend().to_partner_v2(call.order_id)
assert_v2_compatible(legacy, module)
The same discipline runs in the other direction, because Storefront is itself a consumer it cannot control — of the payments team's refund API, which is being rebuilt on their schedule, not yours. Here the team is the tolerant reader: require what you use, keep what you do not understand rather than discarding it, and handle unknown values by policy instead of by exception.
# We consume payments v2 today and payments v3 in November. One reader, both.
KNOWN_OUTCOMES = {"settled", "pending", "declined"}
def read_refund_response(payload: dict) -> "RefundOutcome":
"""Tolerant reader: unknown values are a policy decision, not a crash."""
outcome = payload["outcome"]
if outcome not in KNOWN_OUTCOMES:
outcome = "pending" # treat the unknown as not-yet-final, and alert
known = {"reference", "outcome", "amount_cents"}
return RefundOutcome(
reference=payload["reference"],
outcome=outcome,
amount_cents=payload["amount_cents"],
unrecognized={k: v for k, v in payload.items() if k not in known},
)
Two things are deliberate. Unknown outcomes become pending — the conservative state, because treating an unrecognized value as settled would lose money and treating it as declined would refund a customer twice — and they raise an alert, so tolerance never becomes silence. And unrecognized is kept rather than dropped: when the payments team's v3 adds a dedupe field in November, the evidence that it is arriving is already in the logs.
† Tolerance has limits, and lesson 0057 names them: closed enums, exhaustive matches, signature checks and payload size limits all make "additive" changes breaking. The v2 contract above closes the status enum on purpose — it is a promise that costs the team expressiveness and buys 63 sellers the right to write an exhaustive match. That is the trade, stated rather than discovered.
Decision 6 · Sequence by risk, and keep shipping product
The plan's ordering principle is lesson 0047's: each week must retire the largest remaining uncertainty and leave behind something demonstrable. Note where the product slice lands — week three, before the migration is anywhere near finished. That is not a compromise; it is the point. If the outcome cannot move until the modernization is done, the modernization has become the initiative.
| Week | What ships | Risk it retires | Evidence it is retired |
|---|---|---|---|
| 1 | Golden master over 2,000 orders; recorded partner fixtures; refund-claim wrap; lock-discipline CI check | We cannot tell whether we have changed behavior — and we can repeat INC-2481 and INC-2503 | Returns coverage 12% → 58%; a rehearsed migration fails safely in 2 s; duplicate-refund alert fires in a drill |
| 2 | ReturnsBackend seam with all 6 call sites migrated; expand step; dual write behind a flag; v2 compatibility contract drafted | The two implementations will diverge, and a schema change will strand a consumer | All callers go through the seam (import-linter check); write p95 unchanged; contract signed by Sam and the returns lead |
| 3 | Vertical slice 1, live to staff accounts: a signed-in domestic customer with an unopened item inside 30 days requests a return, sees an eligibility decision in under 800 ms, gets a refund confirmation and a pending label state, and support can see the same states. Served by ModuleBackend for the staff cohort only; blob still authoritative. | Nobody knows whether self-service actually resolves returns faster — the assumption the whole outcome rests on | 50 staff cases end to end; median resolution measured for the first time; two comprehension defects found in the refusal copy |
| 4 | Backfill of 3.1M rows across three nights; drift check every 10 minutes; 412 quarantined rows read by hand | Nine years of blob shapes do not map cleanly, and we would find out during a cutover | Three real drift defects found and fixed; quarantine emptied; replica lag never exceeds 2 s |
| 5 | Read switch to staff then 1%; partner serializer replay test green; accessibility pass on the two critical flows | Reading from the new tables changes a decision, or breaks a seller | Seven consecutive days of zero drift; 4,100 replayed responses byte-identical; WCAG 2.2 AA audit on the request and refusal flows |
| 6 | 10% of eligible customers; returns_v1 view published; Dan begins moving finance's 6 blob queries | Guardrails degrade before we notice — fraud, manual queue, support contacts | Fraud rate flat within noise; manual queue 34/day against a 50 limit; contacts down 18% |
| 7 | 50%; failure drill (carrier timeouts, worker backlog, one duplicated request); runbook and support script handed to on-call | The team cannot operate what it built under failure | Detect, diagnose, limit, recover and explain inside 30 minutes, with the on-call engineer who did not build it driving |
| 8 | 100% of the eligible domestic cohort; pilot review against the outcome and every guardrail | We shipped output instead of an outcome | Median resolution, contact rate and all four guardrails reported by cohort, including the segments that did not benefit |
| Q4 | Flag removal (31 October); warehouse queries moved; contract step 8 after the 90-day window | The temporary machinery becomes permanent | Ledger items closed with dates, not intentions |
Two things are deliberately absent. There is no "refactoring week" — every structural change above is attached to the slice that needs it, per lesson 0047's rule about free-floating architecture runway. And there is no week in which returns cannot be changed; the seam exists from week two precisely so that a production bug and the migration are not competing for the same code.
The dependency you do not own
The payments team's refund-service v3 will not exist before the pilot. Lesson 0047's rule is that "waiting on platform" is not a plan; a dependency needs an owner, a needed-by date, readiness evidence, a fallback and an escalation trigger. All five, written down in week one:
- Owner
- Ravi, payments platform lead. Ours: Marco.
- Needed by
- Never, for the pilot. The design must not assume it.
- Readiness evidence
- Their v3 sandbox accepts an
Idempotency-Keyheader and returns the original reference on replay. Committed 14 November. - Fallback (in force)
- The
RefundClaimwrap from section two: local intent record, unique onreturn_id, no blind retry, plus a daily reconciliation against the payments ledger and an alert at one in-flight claim. - Escalation trigger
- Not a date — an event. If a duplicate refund reaches a customer despite the wrap, it goes to both engineering managers the same day with the incident, not to a roadmap conversation.
- What we did not do
- Ask them to re-prioritize. The fallback is four days of our work; their v3 is a quarter of theirs. Spending influence here would have bought nothing and cost the goodwill needed for the November migration.
Decision 7 · The debt ledger, with interest and triggers
Everything above takes loans. Lesson 0038's rule is that a loan without a ledger entry is never repaid — and in a brownfield initiative the ledger is also the honest answer to "are we leaving it better than we found it?". Each entry names what was borrowed, what it charges per month, what event triggers repayment, and one person who owns it. A trigger that is a date nobody looks at is not a trigger.
| Borrowed | Interest per month | Repayment trigger | Owner |
|---|---|---|---|
Dual write to orders.return_json and the new tables | ≈6 engineer-hours: drift alerts triaged, plus every new return field written and tested twice | Finance's 6 blob queries on returns_v1 and the partner serializer off the blob — then the 90-day window opens | Asha (returns lead) |
Local RefundClaim ledger compensating for a payments API with no idempotency key | ≈2 hours: daily reconciliation review, plus manual handling of in-flight claims (3 in eight weeks) | Payments refund-service v3 in production — or a second duplicate refund reaching a customer, whichever comes first | Marco, with Ravi (payments) |
Finance and the warehouse read orders directly, including the blob | ≈4 hours: two weeks' notice and a re-write for any schema change; one cross-team sync | The third schema change blocked by a consumer we cannot see — or step 7 completing, whichever comes first | Dan (finance), warehouse lead |
| Returns coverage outside the characterized path is still 58%, not 80% | ≈3 hours: a manual regression pass before every returns release | The second escaped defect in returns after the pilot | Asha |
returns_module and returns.dual_write release flags | ≈2 hours: a doubled test matrix on the returns path, and one more branch in every read | 100% for 14 consecutive days. Calendar date: 31 October. The BlobBackend class dies in the same pull request. | Lena (on-call lead) |
| 412 quarantined blobs resolved by hand rather than by rule | ≈0 now; the cost is that the rules that produced them are still undocumented | The first quarantined row created after the backfill — it means a live path still produces an unmappable shape | Marco |
Marketing ETL still runs an unowned SELECT * against the primary | ≈0 in effort; the cost is a recurring outage risk of the INC-2503 shape | Its next failure, or the next migration it delays. Escalated to the data team with the incident attached. | Lena, pending a data-team owner |
The ledger is reviewed in the weekly initiative review alongside the outcome, not filed in a wiki. Two entries were closed during the eight weeks; five were carried past the pilot deliberately, with dates. That is a defensible position — what is not defensible is discovering them in January.
Decision 8 · Deciding not to extract a service
The strongest architectural decision in this case is a refusal. Returns stays inside the monolith as a module, with its own tables, its own package boundary and an enforced import rule — and no separate deployable. Here is the analysis, in the shape lesson 0046 asks for.
| Option | Buys | Costs | Verdict against the drivers |
|---|---|---|---|
| Module inside Storefront — own tables, own package, enforced imports, one deployable | Eligibility and order state commit in one transaction; no new on-call surface; no network hop in the 800 ms budget; the seam and the strangler both work in-process | The boundary is enforced only by a fitness function, not by the network. Returns deploys ride the monolith's train. A bad returns query can hurt checkout. | Accepted. Meets availability, performance and modifiability; costs are visible and testable. |
| Extracted returns service, own database | Independent deploys; a hard boundary; independent scaling | Order truth is remote, so eligibility becomes a distributed read with its own failure mode; a second on-call rotation for a 6-person team; a data migration and a service extraction in the same eight weeks; the partner API would need to call across the boundary | Rejected. Adds an availability risk to solve a modifiability problem the module boundary already solves. |
| Extracted service, shared database | Independent deploys without a data migration | Two writers to one schema — the exact ownership fiction lesson 0058 names, now across a deploy boundary. Every migration becomes a cross-service coordination event. | Rejected. All of the coordination cost, none of the isolation benefit. |
The module boundary is not a promise, it is a check. An import-linter contract in CI fails the build if anything outside storefront.returns imports its internals, or if the returns package imports the orders ORM models directly instead of going through a published read port — the fitness function that makes a modular monolith mean something. Without it, "module" is a directory name.
- Release cadence conflict
- Returns needs to ship daily and the monolith ships twice weekly, and returns releases are blocked by unrelated monolith failures more than twice a month for two consecutive months. Deploy independence is then buying something measurable.
- A second team takes ownership
- If returns is handed to a different team, the boundary becomes a team boundary, and lesson 0033's answer changes: you will pay coordination costs whether or not you designed for them, so design for them.
- Load divergence
- Returns traffic exceeds roughly 15% of Storefront's request budget, or its queries cause replica lag that affects checkout. Today returns is 40 rps against 1,150 — about 3.5%. Independent scaling is not yet worth a network hop.
- A regulatory or data-isolation requirement
- If return evidence (photographs, correspondence) must be stored under a different retention or residency rule than order data, a separate store — and probably a separate service — becomes the cheaper way to prove compliance.
- The blast radius becomes intolerable
- If a returns defect causes a checkout incident, the shared-fate cost has been paid once. Twice is a decision.
Revisit date: the pilot closeout review, and thereafter whenever any threshold above is crossed. The ADR is written in the packet; if it is reversed, a new ADR supersedes it rather than editing it.
Who carries the pager
Extraction questions are usually argued as architecture and settled as operations. Three organizational consequences were written into the decision, because leaving them implicit is how a module becomes an orphan:
- Returns joins the existing Storefront rotation; it does not create a new one. Six engineers cannot staff a second rotation, and a rotation of two is a rotation that burns two people out. The cost is that a checkout engineer will be paged for a stuck return queue, so the runbook, the disable path and the support script were written for that person, not for the team that built it, and drilled in week seven with that person driving.
- Two alerts were added to the shared rotation and one was removed. Adding pages to someone else's night without removing any is how teams lose the right to add anything. The removed one was a returns error-rate alert that had fired 31 times in six months and been actioned zero times.
- The steady-state owner is named before the pilot ends, not after. Per lesson 0050: dashboards, runbook, vendor relationship, the debt ledger and the flag removal dates transfer to a named owner with a date. An initiative that ends without that transfer has produced software and no owner.
Decision 9 · Communicating incremental progress
Incremental modernization has a communication problem that big-bang rewrites do not: there is no launch. Week four looks, from outside, exactly like week two. If the only story you can tell is "still migrating", you will be asked to go faster in ways that reintroduce every risk you removed. The answer is to report evidence retired, not percentage complete — and to say the same true thing at two different depths.
Returns pilot — week 4 of 8
Fifty staff-account returns have now gone end to end through the new flow, and median resolution for those cases is 3.4 hours against a 4-hour target — the first real evidence that self-service moves the outcome, not just the interface. This week we migrated nine years of return history into a proper structure and found three data defects while both the old and new forms are still being written, so none of them reached a customer; we can still revert the whole change with one switch.
Forecast: we are 70% confident the pilot reaches the eligible domestic cohort between 23 and 30 September. The date and the guardrails are fixed; the variable that can move is scope — exchange automation is already out, and if we lose a week the next thing to come out is the bulk-returns screen, not the accessibility work or the fraud checks. One ask: Dan needs four days in September to move six finance reports onto the new tables. Without it we keep paying to write everything twice.
Three properties make that useful rather than reassuring. It reports an outcome number (3.4 hours) rather than an activity number. It states the forecast as a range with a confidence and a named moving variable — lesson 0047's rule, and the language lesson 0050 insists on sharing while options still exist rather than when the miss is certain. And it ends with one decidable ask, not a status. "We are on track" would have been shorter and worth nothing.
- Backfill complete: 3.1M rows across three nights, replica lag peaked at 1.4 s. 412 rows quarantined — all pre-2019 shapes; Marco has read them and 380 map cleanly under a rule we can now write down.
- Drift check found three real bugs: partial-refund rounding differed between the two writers; the admin bulk action wrote the blob directly and bypassed the seam (now routed through it); a Celery task wrote outside the transaction. All three would have been invisible in a cutover.
- Gate for the read switch is unchanged: seven consecutive days of zero drift. We are on day two. If it resets, we do not negotiate the gate — we look at the writer.
- Coverage in
storefront/returns/is 58%. The golden master pins the decision surface; the new module has its own example tests. Do not re-record the golden file to make a build green — if the diff is intentional, say so in the pull request. - Ledger changes: quarantine entry added; the ETL entry escalated to the data team with INC-2503 attached; flag removal still 31 October, still Lena's.
The engineering version is not the executive version with more detail — it carries different decisions. It names the gate and refuses to negotiate it, it names the one behavior that would quietly destroy the safety net (re-recording the golden file), and it moves the ledger. The executive version carries the outcome, the range, and the ask. Neither version contains a sentence the other contradicts, which is the actual test of whether you understand your own progress.
| Do not say | Say | Because |
|---|---|---|
| "We are on track." | "70% confident, 23–30 September; scope is the variable." | A point estimate with no range invites a promise you did not make. |
| "The migration is 60% done." | "Both forms are written and verified; reads switch after seven clean days." | Percentages of a migration are unfalsifiable. Gates are checkable. |
| "We hit a problem." | "The drift check caught three defects before any customer saw them." | Finding defects early is the mechanism working, and should be reported as such — otherwise you are punished for looking. |
| "We need more time." | "Here are two scope trades that preserve the outcome and the date." | Lesson 0050: change the plan visibly, and bring the trade rather than the request. |
| "The other team is blocking us." | "The fallback is in force and costs 2 hours a month; the trigger to escalate is a customer-visible duplicate." | An owned dependency with a fallback is a managed risk. A named blocker is a complaint. |
What this case demonstrates
- The solution does not depend on a big-bang rewrite. The rewrite plan is stated in full and rejected for six specific reasons drawn from this system's coverage, consumers, partners and deadline — not on principle. Every subsequent decision is a small step behind a seam, a flag or a compatibility contract.
- Every migration step is deployable and reversible, or carries explicit compensation. Steps 0–7 each name their reverse and the evidence required before the next one. Step 8 — dropping the column — is the only irreversible act, is deliberately scheduled after the pilot, and carries a stated compensation: a full export with a rehearsed restore and a 90-day read-only window proven by
pg_stat_statements. - Product delivery continues through at least one vertical slice. Week 3 ships a complete customer path to staff accounts — request, eligibility decision inside 800 ms, refund confirmation, pending label, support visibility — while the blob is still authoritative and the migration is a third done. It is the week the initiative first learns whether the outcome moves at all.
- Operational and organizational consequences are part of the design, not a footnote. On-call ownership is decided with the architecture (one rotation, two alerts added and one removed, drilled by the engineer who did not build it); the payments dependency is managed with a fallback, a compensating control and an event-based escalation trigger rather than a re-prioritization request; the debt ledger names an owner and a repayment trigger for each of seven loans; and the steady-state owner is named before the pilot ends.
- Find your blob and your unseen consumers. Name the one data structure in your system whose shape has drifted for years — a JSON column, a status integer, a wide denormalized table. Then find every consumer of it that is not your application code: reports, ETL jobs, another team's service, a spreadsheet someone refreshes. For each, write the change cost in days and who pays it. If you cannot complete that list in an afternoon, the list itself is your first week of work, exactly as it was here.
- Classify your next irreversible step, and design its compensation. Take the riskiest change on your roadmap and split it into the sequence of deploys that would each stand alone. Mark the first one you cannot reverse. For that one, write down the compensation you would actually execute at 3 a.m. — the export, the restore path, the window, the query that proves nobody is still reading. If you cannot write it, you have found the step that should be scheduled after your next deadline, not before it.
- Write one ledger entry with a trigger that will actually fire. Pick the largest loan your team is currently paying interest on. Write the four columns: borrowed, interest per month in hours or incidents, repayment trigger, owner. Then test the trigger: is it an event someone will notice — the second escaped defect, a customer-visible duplicate, the third blocked schema change — or a date in a document nobody opens? Rewrite it until it is an event, then put it on the agenda of a meeting that already happens.
Read and work deeper
- Working Effectively with Legacy Code, Michael Feathers — ch. 4 (seams and enabling points), ch. 6 (sprout and wrap), ch. 13 (characterization tests), ch. 25 (the dependency-breaking catalogue). The source for sections one and two.
- "Strangler Fig Application" and "Branch By Abstraction", Martin Fowler — the two patterns section three distinguishes.
- "Parallel Change", Danilo Sato — expand/contract stated as a general change pattern, not only a database one.
- Monolith to Microservices, Sam Newman — ch. 3 (strangler, branch by abstraction, parallel run) and ch. 4 (decomposing the database, including the shared-database and view patterns used in step 7).
- Refactoring Databases, Ambler & Sadalage — the migration catalogue behind the step table, including the transition window.
- PostgreSQL documentation — explicit locking: the lock-level table that explains INC-2503 and the
lock_timeoutdiscipline. - "Technical Debt", Martin Fowler — the loan framing the ledger in section seven makes operational.
Next: put this judgment under pressure. The decision-change simulations hand you a committed plan and then change the evidence underneath it.
Related lessons: 0033 (which currency you pay), 0035 (flags, strangler, branch by abstraction, expand/contract), 0037 (reversible or irreversible), 0038 (the ledger), 0047 (sequencing and dependencies), 0049 (gates and rehearsed reversal), 0057 (compatibility policy), 0058 (expand/contract and ownership). Reference: glossary — seam, characterization test, strangler fig, branch by abstraction, expand/contract, tolerant reader, feature flag.