The starting point: a flow that works, and still has to go
In September 2026, an online ordering web application runs on the cluster. Server side: Python, FastAPI, SQLite. Visitor side: a seven-step flow rendered by Jinja templates, working without a single line of JavaScript. Each step is a form, each submission a POST followed by a 303 to the next step — or to the same one, with red sentences to fix.
The flow has real qualities: it is tested (more than 1,700 tests on the Python side), it works in any browser, and it does not break when a third-party script goes down. It also has a limit: every correction reloads the page, photo upload is a raw multipart form, and the experience the product needs next cannot be built in plain HTML. On 13 September, the decision is made: the flow moves to React. The “no JavaScript” promise is dropped, deliberately.
The risk is not technical in the “React is hard” sense. It is behavioural. The Jinja flow holds hundreds of small decisions: which field is flagged as wrong, which sentence appears when a quota is hit, when a resume link is emailed, what is written to the database at each step. Rewriting the interface means rewriting those decisions — and silently losing a few.
The plan: a strangler with a switch that disappears at the end
The pattern is known as the strangler fig: grow the new implementation next to the old one, move the traffic, then cut the old one. The version used here fits in fourteen tasks and one sentence: the rules move down into a service, both facades call it, a parity harness guards the switch-over, and the switch disappears at the end instead of staying.
règles hors des routesrules out of the routes
Jinja l’appelle, rien ne bouge à l’écranJinja calls it, nothing moves on screen
interrupteur, défaut falseswitch, default false
la coquille React est montée, invisiblethe React shell is mounted, invisible
Observation(jinja) == Observation(json)Observation(jinja) == Observation(json)
deux clients neufs, deux basestwo fresh clients, two databases
un commitone commit
gabarits, routes et interrupteur retiréstemplates, routes and switch removed
ATTENDUS = mesuresEXPECTED = measurements
la comparaison devient un inventairethe comparison becomes an inventory
Three choices shape everything else.
- The rules leave the routes. Before writing a single React component, each step’s logic (validation, quotas, draft creation, email sending) is extracted into a service module. The Jinja routes call it; visually, nothing changes. This is what lets both facades share the same decisions instead of keeping two copies.
- The JSON facade is mounted behind a switch that defaults to off. On 21 September, the compiled React shell is served under the same URL prefix as the Jinja flow, mounted before the Jinja router so it wins when the switch is on. Default
false: the transition is invisible in production, and no Kubernetes manifest needs to know about the setting. - The switch is temporary by contract. A switch that stays becomes a second application to maintain. It is written from day one as something to remove, in the same commit as the old rendering.
The harness: compare what the user observes, not what the server returns
The naive first idea would be to compare responses. It does not survive a minute: on one side a 303 then an HTML page, on the other a single JSON body. Comparing HTML with JSON is comparing scenery.
So the harness compares an Observation: a normalised structure of what a user observes after a gesture, produced identically by two drivers — one posting forms like a browser without JavaScript, the other posting JSON like the React application. Slightly simplified:
@dataclass(frozen=True)
class Observation:
"""Ce qu'un utilisateur constate, indépendamment du transport."""
etape: str # où il se retrouve
statut: str # "avance" | "arrete" | "commande"
champs: tuple[str, ...] # les champs désignés fautifs, triés
messages: tuple[str, ...] # les phrases rouges, triées
avis: tuple[str, ...] # les avis (détour, retour, lien envoyé), triés
slug: str # la commande créée, sinon ""
brouillons: int # lignes créées ou supprimées par ce POST
commandes: int
courriels: int # e-mails réellement partis
credits: tuple[tuple[str, int], ...] # crédits de quota consommés
(The code base is in French: etape is the step reached, champs the fields flagged as wrong, messages the red sentences, avis the notices, brouillons/commandes/courriels the drafts, orders and emails created, credits the quota credits consumed.)
What it leaves out matters as much as what it keeps. The HTTP status code is not in it: a honeypot field filled in by a bot yields a 400 with the form redisplayed on one side and a 403 on the other. It is the same refusal seen through two transports, with the same sentence, creating nothing. Encoding that difference would turn the harness red on scenery and make it useless as a gate.
Each scenario (a “gesture”, in the harness’s vocabulary) is played twice, on two fresh clients, each with its own SQLite database. That detail cost one false lead: with a shared database, the second facade was refused because of the quota credit the first one had just consumed. The harness then blamed the facades for a divergence caused by its own setup.
On 22 September, the harness holds 27 scenarios. Its first finding is a real defect, not a false positive: the quota-exceeded refusal page said “Nothing is lost…” twice on the Jinja side and once on the JSON side. The Jinja rendering function was enriching a text the service had already enriched. Fix: enriching is the service’s job, the page only displays. Without the harness, that duplicated sentence would have vanished at the switch-over with nobody knowing why it had existed.
What parity cannot carry: freeze it separately
Some properties have no equivalent on both sides. They are frozen separately, on the JSON facade alone, in the same file — because that file is the gate:
- The JSON contract’s status codes (
422to fix,429quota,409lost flow,403refused, then502when the payment provider is unavailable). The React client uses them to pick its screen; they cannot enter the Observation, so a dedicated test pins them. - Known gaps. A refusal’s title is rendered as an
<h1>by Jinja and is carried by no JSON field. The behaviour is identical, the displayed information is not. Rather than discover it after the switch-over, a test declares it.
A subtler case came up in review: a GET on a step not yet reached is served by both facades. Should there be an “anti-skip” guard? The answer was no — not during this phase. Parity is the law: adding a guard on one side only would be exactly the divergence the phase exists to prevent. A GET writes nothing, and every POST validates its own prerequisites; skipping ahead only shows an empty form that the submission will refuse. The added test proves parity, not a guard: the day a guard is decided, it will require it on both sides.
Each check is verified by a bite experiment: deliberately break the behaviour on the JSON side only and watch the test turn red. For the anti-skip guard, red looks like this:
AssertionError: ('apercu-texte', 2) != ('details', 6)
A test that does not bite guards nothing.
The switch-over: one commit, and no URL changes
Before removing anything, one last step: the scenarios’ starting points (“put the user at step 5”) also go through the JSON API. The end-to-end test is rewritten against the API rather than deleted: it observes the current step at each move instead of following a 303. Result: 1,732 green tests, zero skipped, and proof that behavioural coverage survives the removal of the Jinja facade.
Then, on 22 September at 13:07, the flow’s switch-over fits in one commit: the seven steps served by React, nine templates and fourteen routes removed, the switch deleted. No infrastructure manifest moved — that was the whole point of the false default, and a search through the GitOps repository confirms the setting was never referenced there.
The switch-over nearly lost two behaviours silently, caught because the harness described them: an unreadable session cookie had to be ignored and deleted (the Jinja rendering did it in passing), and going back to the first step of an existing flow had to offer the resume link — otherwise the screen had nothing to show.
The same pattern is replayed the next day for the post-order pages (preview, payment, correction, provider returns): a JSON facade with four entry points, a dedicated parity harness, switch-over on 23 September at 15:44. Eleven Jinja routes and four templates go; no URL changes — not the ones in the browser, not the ones in emails already sent, not the return URLs handed to the payment provider. The 92 tests that read HTML are rewritten to assert structured state, not deleted (93 after the rewrite). That evening, once the deployment window had passed — no browser still showing an old form — the three transitional POST routes are removed as well.
After the switch-over: from comparison to inventory
Once the old facade is gone, there is nothing left to compare. The harness does not disappear: it moults. The comparator becomes an inventory, and the expected observations are the ones measured on the previous commit, while the Jinja facade was still in place and the equality test still green.
# Gelé sur le commit d'avant la bascule, quand les deux façades donnaient la même observation
ATTENDUS: dict[str, ObservationCommande] = {
"payer-prestataire-indisponible": _observation(...),
# … 24 observations mesurées, pas écrites à la main
}
# (simplifié) chaque scénario rejoué sur la façade restante doit retrouver sa mesure
def test_le_comportement_est_celui_mesure(scenario):
assert observer_json(scenario) == ATTENDUS[scenario.nom]
(ATTENDUS means “expected”: 24 observations measured, not written by hand.)
The nuance matters. Hand-written expected values describe what you believe the application does. Values measured on the old implementation describe what it did, defects included. Any drift now turns red with the scenario’s name.
The freeze itself found two holes: a “sentence too short” scenario posted exactly three characters, the allowed minimum (it tested nothing), and both bot-protection scenarios failed their own starting point. Fixed, they bite.
| Before | After the flow switch-over | |
|---|---|---|
| Facades | Jinja + JSON | JSON only |
| Parity scenarios | 27 | 34 + 3 resumes, frozen |
| Python suite | 1,732 green | 1,693 green, 0 skipped |
| Front-end tests (Vitest) | — | 83 |
| Front-end bundle | — | 136,943 / 200,000 bytes gz |
The drop from 1,732 to 1,693 tests is not lost coverage: each removed test guarded a route that no longer exists, and each removal is justified in the task report.
Lessons: what carries over elsewhere
- Compare observations, not responses. Define a “what the user observes” structure (step, flagged fields, sentences, database effects) that both implementations produce. Transport (status codes, redirects, envelope) stays out, with a comment saying why.
- Isolate each run. Two fresh clients, two databases. Shared state manufactures false divergences, and a harness that cries wolf soon stops being heard.
- Freeze separately what parity cannot see. Contract codes, known gaps: a declared test beats a surprise in production.
- Fix nothing on one side during the transition. Even a good idea (an anti-skip guard) waits until the end; otherwise parity can no longer be measured.
- Make every test bite. Deliberately break the behaviour and watch it go red. That is the only proof a check guards anything.
- A switch that disappears. Default
false, removed in the switch-over commit along with the templates and routes. Nothing to clean up in the infrastructure. - Freeze measurements, not intentions. After the switch-over, expected values are those observed on the old implementation, not the ones you imagine.
The limit is an honest one: this harness proves that both facades behave the same, not that the new interface is pleasant to use. The React rendering itself is covered by its own tests and by an on-screen review. But for the question “what did we lose along the way?”, the answer fits in one test file — and it is empty.