Strangler Fig lab — migrate a legacy monolith to Go microservices with gateway cutover and contract tests.
Lab project exploring patterns used when modernizing long-lived enterprise retail systems (monolith → services).
Not a real POS. Clean-room educational code only. Author: Aniket Singh · MIT
Long-lived retail backends often start as a single process that owns orders, inventory, pricing, and more. That works until release risk, scaling pressure, and team boundaries force a change. A big-bang rewrite is rarely safe: you cannot freeze the business while you rebuild everything.
The Strangler Fig pattern (Martin Fowler) grows new services around the old system and routes traffic slice by slice until the monolith can be retired. This repo is a tiny, runnable demo of that idea.
Domain (toy only): retail orders + inventory.
flowchart LR
Client([Client / curl]) --> GW[Gateway :8000]
GW -->|"/orders* ROUTE_ORDERS_NEW=true"| ORD[orders-go :8081]
GW -->|"/inventory* ROUTE_INVENTORY_NEW=false"| LEG[legacy monolith :8080]
ORD -->|"reserve stock"| LEG
INV[inventory-go :8082] -.->|"ready for full cutover"| GW
subgraph before["Phase 0 — all legacy"]
L0[legacy: orders + inventory]
end
subgraph now["Phase 1 — partial strangler (default demo)"]
L1[legacy: inventory]
O1[orders-go]
end
subgraph later["Phase 2 — full cutover (flip flag)"]
O2[orders-go]
I2[inventory-go]
end
| Process | Port | Role |
|---|---|---|
| gateway | 8000 |
Path-based cutover router + feature flags |
| legacy | 8080 |
Node “monolith”: orders + inventory in one process |
| orders-go | 8081 |
Go orders microservice (default cutover target) |
| inventory-go | 8082 |
Go inventory microservice (built; not routed by default) |
Default demo routing
POST/GET /orders*→ orders-goGET/POST /inventory*→ legacyorders-gostill reserves stock by calling legacy inventory (realistic partial extract)
Flip cutover without redeploying clients:
ROUTE_ORDERS_NEW=true
ROUTE_INVENTORY_NEW=false # set true to send inventory to inventory-goInspect live routing:
curl -s http://127.0.0.1:8000/__routes | jq .
curl -s http://127.0.0.1:8000/health | jq .Without naming any employer or product line, the same moves show up when modernizing long-lived store / order backends:
| Lab concept | Real-world analogue |
|---|---|
| Monolith owning orders + stock | Single deployable retail core |
| Shared OpenAPI + contract tests | Consumer-driven / schema contracts before cutover |
| Gateway path flags | Edge routing, feature flags, or service mesh traffic split |
| Extract orders first | Highest-change domain peeled off while inventory stays put |
| New service still calls old inventory | Temporary anti-corruption / façade dependency during migration |
X-Served-By response header |
Observability so you can prove which backend answered |
What a recruiter or hiring manager should notice:
- Pattern fluency — strangler fig, not rewrite theatre
- Contract-first cutover — same request/response shapes on both sides
- Working Go microservices — modules,
net/httprouting, tidy deps - Operational honesty — smoke script, health endpoints, config-driven routing
- Communication — README + mermaid that explain why, not only what
Prerequisites: Go 1.22+, Node 18+, Make (optional).
git clone https://github.com/acephos/strangler-lab.git
cd strangler-lab
# One-shot: build, start stack, create-order flow, tear down (exit 0 on pass)
make smoke
# or
node scripts/smoke.js --startForeground stack for exploration:
make up
# another terminal:
node scripts/smoke.js
curl -s http://127.0.0.1:8000/__routesContract tests (legacy vs orders-go against the shared orders shape):
make contract
# or
cd contracts && npm testDocker (optional):
docker compose up --build
# then: node scripts/smoke.js # GATEWAY_URL defaults to :8000# Inventory still on legacy
curl -s http://127.0.0.1:8000/inventory/SKU-COFFEE-01 | jq .
# Create order — gateway sends this to orders-go
curl -s -X POST http://127.0.0.1:8000/orders \
-H 'content-type: application/json' \
-d '{"customerId":"cust-42","items":[{"sku":"SKU-COFFEE-01","quantity":2}]}' | jq .
# Fetch order
curl -s http://127.0.0.1:8000/orders/<id> | jq .Response headers tell the story:
X-Served-By: orders-goorlegacy-monolithX-Gateway-Target: http://127.0.0.1:8081(upstream base the gateway chose)
/legacy Node monolith (orders + inventory, in-memory)
/services/orders-go Go orders API (calls inventory for reserve)
/services/inventory-go Go inventory API (second slice)
/gateway Go edge router + cutover flags
/contracts OpenAPI 3 + Node contract tests
/scripts dev-up + smoke
docker-compose.yml optional multi-container stack
Makefile build / up / smoke / contract
| Method | Path | Body / notes |
|---|---|---|
GET |
/health |
liveness |
POST |
/orders |
{ "customerId", "items":[{ "sku", "quantity" }] } → 201 Order |
GET |
/orders/:id |
Order or 404 |
GET |
/inventory/:sku |
Stock or 404 |
POST |
/inventory/:sku/reserve |
{ "quantity" } → reserved / remaining |
Full schema: contracts/openapi.yaml.
Seed SKUs: SKU-COFFEE-01, SKU-MUG-12, SKU-FILTER-100.
- Phase 0 — all traffic to legacy (
ROUTE_ORDERS_NEW=false,ROUTE_INVENTORY_NEW=false). - Phase 1 (default) — extract orders; inventory stays; contracts green.
- Phase 2 — start
inventory-go, setROUTE_INVENTORY_NEW=true, pointorders-goINVENTORY_URLat it, re-run smoke. - Phase 3 — delete unused legacy handlers once traffic and metrics agree.
That sequence is the whole point of the lab: incremental, reversible, contract-guarded.
- In-memory stores only — no DB, no Kubernetes, no message bus.
- Errors are JSON
{ "error": "..." }with stable HTTP status codes (400/404/409). - Go code uses the standard library (
net/httppath patterns) plusgoogle/uuidfor order IDs. - Contract tests boot legacy +
orders-gothemselves so CI does not need Docker.
MIT © Aniket Singh — see LICENSE.
Topics: go · microservices · strangler-fig · modernization · architecture · contract-testing