Software Design Reference Case study · Brownfield modernization

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.

Reading 30 minWorking through it with your own system: 2–3 hoursPrerequisite: lessons 0033, 0035, 0037, 0039–0050, 0057, 0058

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.

System inventory · Storefront
FactNumberWhy it constrains the design
Age and shapeFirst commit March 2017; 312,000 lines of Python; one Django deployable; releases Tuesday and ThursdayOne deploy unit means one blast radius. Every returns change ships with everything else.
DatabasePostgreSQL 12, one primary and one read replica, 217 tables in publicNo schema documentation. The schema is the integration contract, whether anyone signed it or not.
The bloborders.return_json (jsonb), non-null on 3.1M of 41.2M orders rows; 11 distinct key-shapes accumulated since 2017; largest value 84 KBReturn state has no schema, no constraints, no history and no index. Every rule reads it defensively.
Load340 rps median, 1,150 rps peak; returns endpoints 40 rps at peak; partner API 4.2 rps40 rps at 800 ms p95 is the performance driver from lesson 0043. It is not large. Availability, not throughput, is the pressure.
Tests31% line coverage overall; 12% under storefront/returns/; assess_return() is one 900-line functionYou cannot refactor what you cannot verify. Coverage is the first thing to buy, not the last.
Team6 engineers (4 backend, 1 frontend, 1 platform at 50%), 1 designer, 1 PMSix engineers cannot run two systems. That single fact decides most of section nine.
On-callShared 6-person Storefront rotation with the checkout team; one week in threeAnything 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.

Undocumented consumers of the schema
ConsumerWhat it readsChange cost
Finance reporting (Metabase)34 saved queries over orders, order_lines; 6 of them parse return_json with ->>; 2 month-end reconciliationsOne 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_jsonA 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 minutesNobody 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.

The plan that gets proposed in the first meeting — and why it fails here

"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:

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:

Field notes · what to pin first, and why
Candidate behaviorFailure if it changes silentlyPinned in week 1?
The eligibility decision and its refusal reason for a sampled 2,000 ordersCustomers 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 sellerNine 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 readThe 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 layoutCosmetic. 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.

Field notes · picking the move
What you needMoveWhat it costs
New behavior in an untestable methodSprout a tested function; call it onceThe legacy method stays ugly. Accept it — you are not paid to make it pretty this week.
Something to happen on every existing callWrap: rename the old body, take its nameOne extra indirection, and a name that now means two things until the old body dies.
To swap an implementation behind 40 call sitesObject seam + branch by abstractionAn interface that must be right enough to hold both implementations.
To change what a whole request path doesRouting seam at the edge (next section)Two live code paths, and the discipline to delete one.
To make a global dependency testable at allParameter 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 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.

Migration steps · each deployable, each reversible or compensated
#StepReverse / compensationEvidence required before the next step
0Lock 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.
1Expand. 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.
2Dual 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.
3Backfill 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.
4Verify. 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.
5Switch 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.
6Partner 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.
7Move 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.
8Contract — 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.

Compatibility contract · partner API v2 (written in week 2, published week 3)
QuestionThe 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 valuesstatus 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 windowN−1 supported for 12 months after the successor ships; 90 days' written notice before any deprecation takes effect.
What we ask of partnersRead 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 enforcedA replay test over 4,100 recorded responses runs in CI. Breaking the contract fails the build, not production.
OwnerSam (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.

Risk-based sequence · 4 August – 30 September
WeekWhat shipsRisk it retiresEvidence it is retired
1Golden master over 2,000 orders; recorded partner fixtures; refund-claim wrap; lock-discipline CI checkWe cannot tell whether we have changed behavior — and we can repeat INC-2481 and INC-2503Returns coverage 12% → 58%; a rehearsed migration fails safely in 2 s; duplicate-refund alert fires in a drill
2ReturnsBackend seam with all 6 call sites migrated; expand step; dual write behind a flag; v2 compatibility contract draftedThe two implementations will diverge, and a schema change will strand a consumerAll callers go through the seam (import-linter check); write p95 unchanged; contract signed by Sam and the returns lead
3Vertical 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 on50 staff cases end to end; median resolution measured for the first time; two comprehension defects found in the refusal copy
4Backfill of 3.1M rows across three nights; drift check every 10 minutes; 412 quarantined rows read by handNine years of blob shapes do not map cleanly, and we would find out during a cutoverThree real drift defects found and fixed; quarantine emptied; replica lag never exceeds 2 s
5Read switch to staff then 1%; partner serializer replay test green; accessibility pass on the two critical flowsReading from the new tables changes a decision, or breaks a sellerSeven consecutive days of zero drift; 4,100 replayed responses byte-identical; WCAG 2.2 AA audit on the request and refusal flows
610% of eligible customers; returns_v1 view published; Dan begins moving finance's 6 blob queriesGuardrails degrade before we notice — fraud, manual queue, support contactsFraud rate flat within noise; manual queue 34/day against a 50 limit; contacts down 18%
750%; failure drill (carrier timeouts, worker backlog, one duplicated request); runbook and support script handed to on-callThe team cannot operate what it built under failureDetect, diagnose, limit, recover and explain inside 30 minutes, with the on-call engineer who did not build it driving
8100% of the eligible domestic cohort; pilot review against the outcome and every guardrailWe shipped output instead of an outcomeMedian resolution, contact rate and all four guardrails reported by cohort, including the segments that did not benefit
Q4Flag removal (31 October); warehouse queries moved; contract step 8 after the 90-day windowThe temporary machinery becomes permanentLedger 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:

Dependency · payments idempotent refunds
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-Key header and returns the original reference on replay. Committed 14 November.
Fallback (in force)
The RefundClaim wrap from section two: local intent record, unique on return_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.

Debt ledger · returns modernization, reviewed in the weekly initiative review
BorrowedInterest per monthRepayment triggerOwner
Dual write to orders.return_json and the new tables≈6 engineer-hours: drift alerts triaged, plus every new return field written and tested twiceFinance's 6 blob queries on returns_v1 and the partner serializer off the blob — then the 90-day window opensAsha (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 firstMarco, 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 syncThe third schema change blocked by a consumer we cannot see — or step 7 completing, whichever comes firstDan (finance), warehouse lead
Returns coverage outside the characterized path is still 58%, not 80%≈3 hours: a manual regression pass before every returns releaseThe second escaped defect in returns after the pilotAsha
returns_module and returns.dual_write release flags≈2 hours: a doubled test matrix on the returns path, and one more branch in every read100% 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 undocumentedThe first quarantined row created after the backfill — it means a live path still produces an unmappable shapeMarco
Marketing ETL still runs an unowned SELECT * against the primary≈0 in effort; the cost is a recurring outage risk of the INC-2503 shapeIts 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 comparison · where returns lives for the pilot
OptionBuysCostsVerdict against the drivers
Module inside Storefront — own tables, own package, enforced imports, one deployableEligibility 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-processThe 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 databaseIndependent deploys; a hard boundary; independent scalingOrder 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 boundaryRejected. Adds an availability risk to solve a modifiability problem the module boundary already solves.
Extracted service, shared databaseIndependent deploys without a data migrationTwo 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.

Drivers that would reverse this decision — with thresholds, so it can actually be revisited
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:

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.

Weekly update · to the executive sponsor (week 4)

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.

Weekly update · to engineering (week 4)

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.

Field notes · forecast language that survives contact
Do not saySayBecause
"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

Acceptance criteria met
Apply it to your own system
  1. 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.
  2. 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.
  3. 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

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.