Contents
By the time the paying-user number stopped being arguable, we had earned the right to build the real thing. Three months of the FastAPI proxy hack had turned “would anyone actually pay for a course?” into a hard number — roughly 6,000 users on the Happy Thoughts platform, about one in ten of them paying — and those same three months had turned the proxy itself into something I no longer trusted. The two facts arrived together, and that was the whole signal: demand was proven, and the thing holding it up was creaking exactly as a hack should.
The proxy had been a deliberate, well-labelled shortcut: a thin caching layer in front of a slow Directus CMS, with CCAvenue payments bolted on. It did its job. But every month we shipped more onto it, the cache-invalidation edge cases and the race conditions around payment callbacks — two requests interleaving in a way that leaves the data in a state neither of them intended — got harder to trace. The bug reports were starting to sound like ghost stories. It was time.
So the question was not whether to rebuild. It was how to replace a live system — one that was earning money and serving thousands of people on mobile data plans across India — without ever turning it off.
The obvious plan, and why it quietly kills companies
The plan everyone reaches for first is the big-bang rewrite: stand up the new system in parallel, build it until it reaches feature parity with the old one, and then, on some Saturday night, flip a switch and cut everything over at once. Old system off, new system on, fireworks.
I have watched this plan go wrong enough times to treat it as a default no. The problem is structural, not a matter of discipline. A big-bang rewrite is a bet that pays out only at the very end — you get zero value until the moment of the cutover, and all of the risk lands in that same moment. While you’re building the parallel system, the old one keeps changing underneath you, so parity is a moving target you never quite hit. And because the switch is all-or-nothing, there is no small, safe way to be wrong. If the new system misbehaves at 2am with real users and real payments flowing, your only “rollback” is to flip the whole thing back and hope nothing in between got corrupted. You find out whether months of work were correct at the worst possible time to find out.
For a solo-led rebuild on a product that was actively earning, that was not a bet I was willing to place. I wanted a way to build the new system that paid me back continuously and let me be wrong cheaply.
The strangler fig
The pattern that gives you exactly that is the strangler fig, named by Martin Fowler after a real tree. An Australian strangler fig starts life up in the canopy, sends roots down around an existing host tree, and grows its own structure in place around the old one. Over years the fig becomes self-supporting, the host inside gradually dies back, and eventually the fig stands on its own where the old tree used to be — with no single day on which the forest fell over.
Translated to software, the strangler fig is a migration strategy: you grow the new system around the old one, move functionality across one piece at a time, and let the old system shrink until there is nothing left inside it worth keeping alive. Then you retire it. There is never a big-bang cutover, because the cutover happens continuously, one small reversible step at a time. Each step ships value on its own, and each step can be undone on its own if it turns out wrong.
The catch is that the strangler fig needs somewhere to grow — a single point where you can intercept every request and decide, per request, whether the old system or the new one handles it. Without that interception point, you’re back to a big bang. And this is where the proxy hack paid off a second time.
The seam was already there
The proxy already sat in front of Directus as the single entry point for the whole app. Every client — the web frontend, the beginnings of the Android app — talked to the proxy, never to Directus directly. In architecture terms that layer was a facade: a single stable front door that hides whatever machinery lives behind it. And a facade is exactly a seam — a place where you can change what’s behind the interface without anything in front of it noticing.
That seam was the gift. Because no client knew or cared whether a given response came from Directus or from somewhere new, I could put a second system behind the same door and route to it one endpoint at a time. The app would keep calling /courses and /payments/initiate exactly as before; only the thing answering those calls would quietly change.
So the proxy stopped being just a cache and became the strangler’s router. Its job was now a routing decision: for each incoming route, is this one served by new Django, or does it still fall through to old Directus? Conceptually the whole strangler lives in one data structure — a set of routes that have graduated to the new world:
# The strangler seam, as a mental model: one set decides who serves each route.
# Everything not listed here still falls through to Directus, untouched.
MIGRATED_TO_DJANGO = {
"POST /payments/initiate",
"POST /payments/callback",
"GET /courses", # moved last Tuesday
}
def route(request):
key = f"{request.method} {request.path}"
if key in MIGRATED_TO_DJANGO:
return forward_to_django(request)
return forward_to_directus(request) # the shrinking old world
That set is the idea. In production the idea lived one layer lower, in the gateway — the nginx that already sat in front of everything and terminated TLS for the whole platform. A gateway is just a reverse proxy: a single public address that receives every request and forwards it to whichever internal service should handle it, so clients never see the machinery behind it. Putting the routing decision in nginx instead of in Python meant the seam was made of config, not code — a decision the edge could take before a request ever touched an application process, and one I could change and reload without a deploy.
An nginx location block matches a request path and says where it goes. The rule that makes the strangler real is precedence: nginx matches the most specific location first, so I could carve out exactly the endpoints Django had earned and let a catch-all / sweep everything else back to the old world.
upstream django_backend { server 127.0.0.1:8000; } # the growing new world
upstream directus_proxy { server 127.0.0.1:8055; } # the shrinking old world
server {
listen 443 ssl;
server_name api.example.org;
# --- Endpoints that have graduated to Django, one location per cutover ---
# `= /path` is an exact match and beats the `/` prefix below, so only these
# exact routes go to Django. Add a block to migrate; delete it to roll back.
location = /payments/initiate { proxy_pass http://django_backend; }
location = /payments/callback { proxy_pass http://django_backend; }
location = /courses { proxy_pass http://django_backend; } # moved last Tuesday
# --- Everything not carved out above still falls through to Directus ---
location / {
proxy_pass http://directus_proxy;
}
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Moving an endpoint was now adding one location block; rolling it back was deleting that block and running nginx -t && nginx -s reload. That reload is graceful — nginx spins up new worker processes with the new config and lets the old workers drain their in-flight requests, so no connection is dropped and no user sees a blip. If a freshly migrated /courses misbehaved in production, I didn’t roll back a release or restore a database. I deleted three lines, reloaded, and the request fell back through to Directus, which was still sitting right there, still working. Being wrong cost seconds, not a weekend.
flowchart LR
C[Clients<br/>web and android] --> P[Proxy<br/>the strangler seam]
P -->|routes not yet migrated| D[Directus<br/>the shrinking old world]
P -->|routes moved over| J[Django and DRF<br/>the growing new world]Django, DRF, and a test suite as the safety net
The new world was Django with Django REST Framework — DRF for short. Django gave me a real data model, migrations, an admin, and an ORM I trusted; DRF gave me the API layer on top. The one DRF concept worth naming here is the serializer: a small class that defines how a database object turns into the JSON an API returns, and how incoming JSON turns back into a validated object. It’s the translation layer between Django’s models and the wire.
Two systems, one set of rows
The first problem a strangler hits is data. Directus already owned a PostgreSQL database — every course, order, and enrolment lived in tables that it created and managed. A big-bang rewrite would have copied that data into a fresh Django schema and tried to keep the two in sync during the migration, which is its own class of nightmare: dual-writes, drift, and reconciliation jobs. I refused to have two copies of the truth.
So Django and Directus shared the same rows in the same tables. Django’s ORM has exactly the escape hatch for this: Meta.managed = False tells Django do not create, alter, or drop this table — assume it already exists and just read and write it, and Meta.db_table points the model at the physical table Directus made. The model becomes a typed view over someone else’s schema.
# happy_thoughts/courses/models.py
from django.db import models
class Course(models.Model):
"""Maps onto the table Directus already owns. Django does not manage
its schema; both systems read and write these same rows during migration."""
# Directus names its PK `id` and uses a UUID; we mirror it exactly.
id = models.UUIDField(primary_key=True)
status = models.CharField(max_length=32) # Directus workflow field
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
price_paise = models.PositiveIntegerField() # money in paise, never float
summary = models.TextField(blank=True)
date_created = models.DateTimeField() # Directus system field
date_updated = models.DateTimeField(null=True)
class Meta:
managed = False # Directus owns the schema, not Django
db_table = "courses" # the exact table Directus created
The one gotcha worth flagging for the next engineer: because managed = False, Django’s test runner will not create this table in the test database, so tests fail with relation "courses" does not exist. I handled it with a tiny migration that creates the table only under test (or, more simply, a session-scoped pytest fixture that runs the raw CREATE TABLE against the test DB). The rule is: the migration is faithful to Directus’s DDL, never a second source of truth.
With reads and writes landing on the same physical rows, a migrated endpoint wasn’t operating on a copy that might have drifted — it was operating on the data, live, next to Directus. That is what makes per-endpoint rollback safe: falling back to Directus can’t lose a write, because both systems were always writing to the same place.
The serializer, and the shape contract
The serializer is where the strangler discipline actually lived, because of one rule I held to without exception: a migrated endpoint had to return the byte-compatible JSON shape the proxy already served. The frontend was written against Directus-shaped responses. If I changed the shape while moving the endpoint, I’d be doing two risky things at once — migrating and redesigning — and I’d lose the ability to say cleanly whether a bug came from the move or from the redesign. So the first Django serializers were deliberately unglamorous: they mirrored the old field names, even the slightly awkward ones Directus wrapped its list responses in.
# happy_thoughts/courses/serializers.py
from rest_framework import serializers
from .models import Course
class CourseSerializer(serializers.ModelSerializer):
# Directus exposed the PK as `id` and money as rupees in a string field.
# We reproduce its exact wire names, not the names we'd choose today.
price = serializers.SerializerMethodField()
class Meta:
model = Course
fields = ["id", "status", "title", "slug", "price", "summary"]
def get_price(self, obj: Course) -> str:
# Directus served "499.00"; the Android app parses that string.
# We match it to the paise, not "improve" it into an integer.
return f"{obj.price_paise / 100:.2f}"
Directus wraps collection responses in a {"data": [...]} envelope, so the view had to reproduce that envelope too — not DRF’s default bare list. Redesigning the API into something cleaner came later, as its own separate, honest piece of work, gated behind its own version — never smuggled in under a migration.
Proving parity before flipping
“Same shape” is a claim, and I don’t flip an endpoint on a claim. Before a location block moved to Django, a parity check hit both backends with the same request and diffed the two responses. Only a clean diff — modulo a small allow-list of fields that are legitimately non-deterministic, like a server timestamp header — earned the cutover.
# tools/parity_check.py — run against staging before flipping an endpoint.
import sys
import httpx
from deepdiff import DeepDiff
OLD = "http://directus-proxy.internal:8055" # the shrinking old world
NEW = "http://django.internal:8000" # the growing new world
# Fields that are allowed to differ (non-deterministic), by json-path.
# exclude_regex_paths runs re.search on each path, so this must be a real
# regex: escape the literal brackets and match the array index with \d+.
# A naive "root['data'][*]['date_updated']" never matches — [*] would
# demand a literal '*' and the brackets become character classes.
IGNORE = [r"root\['data'\]\[\d+\]\['date_updated'\]"]
def parity(path: str) -> DeepDiff:
old = httpx.get(OLD + path, timeout=10).json()
new = httpx.get(NEW + path, timeout=10).json()
return DeepDiff(old, new, exclude_regex_paths=IGNORE, ignore_order=False)
if __name__ == "__main__":
diff = parity(sys.argv[1])
if diff:
print("PARITY FAIL:\n", diff.pretty())
sys.exit(1)
print("PARITY OK — safe to flip")
The first time I ran this against /courses it failed loudly: Django was serializing price as the number 499.0 while Directus served the string "499.00", and the diff caught it before a single real client did. That failure is the entire value of the discipline — it turned “looks the same to me” into a machine-checked fact.
The other half of the safety net was pytest, the Python testing framework we standardised on. The parity check proves equivalence today, against a running Directus; the pytest test freezes that equivalence forever, so the shape can’t drift once Directus is gone. Before I moved an endpoint, I captured the old response as a golden fixture and pinned Django’s output against it:
# tests/test_courses_contract.py
import json
from pathlib import Path
GOLDEN = json.loads((Path(__file__).parent / "golden/courses.json").read_text())
def test_courses_matches_directus_shape(client, seed_courses):
"""Django's /courses must byte-match the shape Directus served.
The golden fixture was captured from the live proxy before cutover."""
resp = client.get("/courses")
assert resp.status_code == 200
body = resp.json()
assert set(body.keys()) == {"data"} # the envelope survives
assert set(body["data"][0].keys()) == set(GOLDEN["data"][0].keys())
assert body["data"][0]["price"] == "499.00" # string, two decimals
That test was the contract. When the Django version made it pass, I knew the new endpoint was a faithful replacement, not merely a plausible-looking one — and the test stayed as a permanent guard against the shape drifting later. This is the point in the series where the codebase stopped being a hack with tests bolted on and became a real platform: Django, DRF, and pytest, with the proxy demoted from load-bearing infrastructure to a routing shim on its way out.
The order of strangling
You don’t strangle randomly — the order is a judgment call, and it’s where the strategy earns its keep. I moved the grubbiest, highest-risk pieces first: payments and the auth around them. That’s counter-intuitive; the tempting move is to start with something easy for a quick win. But payments were the part of the proxy hack I trusted least and the part where a bug costs actual money, so getting them onto a tested, real foundation first bought the most peace of mind per unit of work. Content reads — Directus’s genuine strength, the thing it was actually good at — I moved last, precisely because they were the least broken and least urgent.
flowchart LR
A[Phase 1<br/>Directus serves all<br/>Django empty] --> B[Phase 2<br/>payments and auth on Django<br/>Directus serves content]
B --> E[Phase 3<br/>most routes on Django<br/>Directus read-only, shrinking]
E --> F[Phase 4<br/>Directus retired<br/>Django owns everything]The cutover checklist
Because every endpoint moved the same way, the move became a checklist, not a judgment call each time. Boring is the point: a strangler survives on repeatable, identical steps, so no cutover is a special case anyone has to think hard about at 11pm.
CUTOVER: <METHOD /path>
[ ] Django model maps the Directus table (managed=False, db_table set)
[ ] Serializer reproduces the exact wire shape (envelope + field names)
[ ] pytest contract test green against the captured golden fixture
[ ] tools/parity_check.py <path> prints PARITY OK on staging
[ ] Django endpoint deployed and warm (health check passing)
[ ] Add the `location = /path` block -> nginx -t -> nginx -s reload
[ ] Watch p95 latency + 5xx for this route for 15 min
[ ] Confirm writes land on the shared rows (spot-check one record in DB)
ROLLBACK (any time, ~10s):
[ ] Delete the `location` block -> nginx -t -> nginx -s reload
Request falls straight back through to Directus on the shared rows.
The rollback line is the one that let me sleep. Because the seam was config and both systems wrote the same rows, undoing a cutover was never a release rollback or a data restore — it was deleting one block and reloading nginx gracefully. Ten seconds, zero dropped connections, no lost writes. I could migrate an endpoint at 2pm on a Tuesday with real payments flowing, watch it, and if the graphs so much as twitched, be fully back on Directus before a support ticket could be filed.
Directus never got a dramatic shutdown. It just kept doing less. Each deploy moved another route into the migrated set, Directus’s surface area shrank by one, and one day the set contained everything and Directus contained nothing anyone still called. We turned it off the way you finally unplug a server you’ve already stopped depending on: as an afterthought, with no ceremony and nothing riding on it.
What it bought
The whole rebuild happened with the platform live the entire time. No maintenance window, no cutover night, no “the app will be down Saturday” banner shown to 6,000 users, roughly 600 of them paying customers who would have noticed. Every step shipped value on its own and was reversible on its own. The riskiest change I made on any given day was one line in a routing set, and the blast radius of getting it wrong was a few seconds and a one-line revert. I got to be wrong often and cheaply, which is the only way I know to be right eventually.
The takeaway for the next engineer
When you’ve outgrown a system, the instinct is to rebuild it clean and cut over in one heroic switch. Resist it. The big-bang rewrite concentrates all your value and all your risk into a single future moment, and it makes “we were subtly wrong” indistinguishable from “the site is down.” The strangler fig spreads both across dozens of small, reversible steps, each one shippable and undoable on its own.
It costs you something real: you have to build and maintain a seam, you have to keep the old system alive and running longer than feels tidy, and you have to hold the discipline of migrating the shape faithfully before you’re allowed to improve it. That tax is worth paying every time. A rebuild you can pause, reverse, and ship in pieces is a rebuild that can’t take the whole product down with it — and on a live system that’s already earning, that’s not a nicety, it’s the entire game.
The very first thing I strangled was payments — because it was both the grubbiest corner of the hack and the one place a bug spends your users’ money. Getting that right, and making sure I never had to hand-write CCAvenue’s encryption dance again, is where this goes next.
