---
title: 'Architectural Migration Strategies'
source: 'https://academia.sh/en/courses/service-architectures/architectural-migration-strategies'
course: 'Service Architectures'
language: en
updated: '2026-08-23T07:00:29+00:00'
license: 'CC BY-SA 4.0'
---

# Architectural Migration Strategies

Moving from a working monolith to a gradual decomposition: the code-side measure of the strangler fig pattern — shared logic held in two codebases, a single rate change touching two files, paths diverging, and single-step reversibility through a routing switch.

Five forms were built, and each one wrote its own three columns. What was never taken on was
how to move from a working monolith to one of these forms. This lesson measures the migration
itself.

Moving the entire system to a new form in one shot means taking on every measured cost at
once. Instead, the path is decomposed one route at a time: a single step of a workflow moves
to a new unit, the rest stays in the monolith, and a switch decides which path traffic goes
down. As more routes move, the old body shrinks. This arrangement is called the **strangler
fig** migration; the Case Studies course measured the traffic side of this pattern, what gets
measured here is the **code side**.

**AO7.** The new route carries a single step: the late fee calculation. The rest of the loan
workflow stays in the monolith. What is measured is not traffic share, it is the cost of
holding the same logic in two codebases.

## The Switch

The old route is the fee rule itself, from the first lesson.

```js
// monolith/fee.mjs — the fee rule on the old path
// RULE START
export const DAILY_RATE = 2;
export const fee = (days) => (days > 0 ? days * DAILY_RATE : 0);
// RULE END
```

The router sits between the workflow and the fee step. While the switch is off, the call
stays local; while it is on, it goes to the new unit over the network.

```js
// monolith/router.mjs — strangler fig switch; the step goes to the old or new path
import { fee as oldFee } from "./fee.mjs";
export async function calculateFee(days) {
  const p = process.env.NEW_FEE;
  if (!p) return { fee: oldFee(days), route: "old" };
  return (await fetch(`http://127.0.0.1:${p}/calculate?days=${days}`)).json();
}
```

```js
// monolith/app.mjs — loan workflow; only the fee step goes through the router
import { calculateFee } from "./router.mjs";
const state = { book: "on_shelf", openLoans: 1, limit: 5 };
const days = Number(process.argv[2] ?? 3);
if (state.openLoans >= state.limit) { console.log("loan limit exceeded"); process.exit(0); }
state.book = "on_loan";
state.openLoans += 1;
const { fee, route } = await calculateFee(days);
console.log(`loan issued: book 1 -> member 4, ${days} days late, fee ${fee} (fee route: ${route})`);
```

The new unit carries the rule in its own codebase. It has to carry it: being a separate
deployment unit means it cannot import the monolith's files.

```js
// fee-service.mjs — new path; the same rule in a second codebase
import { createServer } from "node:http";
const port = Number(process.argv[2]);
if (!port) { console.log("usage: node fee-service.mjs <port>"); process.exit(0); }
// RULE START
export const DAILY_RATE = 2;
export const fee = (days) => (days > 0 ? days * DAILY_RATE : 0);
// RULE END
createServer((request, response) => {
  const days = Number(new URL(request.url, "http://y").searchParams.get("days"));
  response.end(JSON.stringify({ fee: fee(days), route: "new" }));
}).listen(port, () => console.log(`fee ${port}`));
```

```sh
node monolith/app.mjs 3
```

```
loan issued: book 1 -> member 4, 3 days late, fee 6 (fee route: old)
```

```sh
# start.sh — the new fee process comes up, the same request runs with the switch on
node fee-service.mjs 8797 >fee.log 2>&1 & echo $! >fee.pid
for i in $(seq 40); do curl -s -o /dev/null "http://127.0.0.1:8797/" && break; sleep 0.2; done
NEW_FEE=8797 node monolith/app.mjs 3
```

```
loan issued: book 1 -> member 4, 3 days late, fee 6 (fee route: new)
```

Both paths gave the same result. This is the desired state of the migration: when the new
path opens, nothing looks different from outside. The only difference is in the `fee route`
field.

## Two Codebases

What gets measured is not that these two runs are equal, but the cost of keeping that
equality up.

```js
// duplication.mjs — measures the same business rule being held in two codebases
import { readFileSync } from "node:fs";
const rule = (path) => {
  const s = readFileSync(path, "utf8").split("\n");
  return s.slice(s.indexOf("// RULE START") + 1, s.indexOf("// RULE END"))
    .map((x) => x.trim()).filter(Boolean);
};
const old = rule("monolith/fee.mjs"), current = rule("fee-service.mjs");
const same = old.filter((s) => current.includes(s));
console.log(`rule line: old ${old.length}, new ${current.length}, identical ${same.length}`);
console.log(`codebase holding the same logic: 2   file touched by a rate change: 2`);
console.log(`routing switch: NEW_FEE   rollback step: 1   redeploy: 0`);
```

```sh
node duplication.mjs
```

```
rule line: old 2, new 2, identical 2
codebase holding the same logic: 2   file touched by a rate change: 2
routing switch: NEW_FEE   rollback step: 1   redeploy: 0
```

Both lines of the rule sit identical in the two codebases. For the duration of the migration
this is not a choice, it is a necessity: as long as the old route still takes traffic it has
to carry the logic, and the new route has to carry its own copy too, because it cannot reach
the other's files.

## Double Maintenance

The cost surfaces when the rule changes. Say the daily rate is updated in the new codebase
only.

```sh
# diverge.sh — the rate changes only in the new codebase; the two paths diverge
kill "$(cat fee.pid)"
sleep 0.3
sed -i.bak 's/export const DAILY_RATE = 2;/export const DAILY_RATE = 5;/' fee-service.mjs
rm -f fee-service.mjs.bak
node fee-service.mjs 8797 >>fee.log 2>&1 & echo $! >fee.pid
for i in $(seq 40); do curl -s -o /dev/null "http://127.0.0.1:8797/" && break; sleep 0.2; done
NEW_FEE=8797 node monolith/app.mjs 3
node monolith/app.mjs 3
```

```
loan issued: book 1 -> member 4, 3 days late, fee 15 (fee route: new)
loan issued: book 1 -> member 4, 3 days late, fee 6 (fee route: old)
```

Same member, same book, same delay — two different fees. Which amount applies is decided not
by the business rule, but by which path the request happened to land on. This is a failure
mode specific to the migration period, and it needs no process to crash: both codebases are
running, and each one is correct on its own terms.

The measure is this: for the duration of the migration, every change touching the fee rule
has to be written to **two files**. This cost is not multiplied by the number of routes
moved, it is multiplied by how long the move takes. A migration left half-finished means
permanent double maintenance.

## Reversibility

The pattern's real payoff sits in the same place.

```sh
# rollback.sh — the routing switch is turned off; the new service is not stopped
echo "switch off, no redeploy"
node monolith/app.mjs 3
echo "new service still up: $(curl -s 'http://127.0.0.1:8797/calculate?days=3')"
```

```
switch off, no redeploy
loan issued: book 1 -> member 4, 3 days late, fee 6 (fee route: old)
new service still up: {"fee":15,"route":"new"}
```

The old behavior came back in a single step: an environment variable was not supplied, that
is all. The new service was not stopped, nothing was redeployed, no file changed. Had the old
code been deleted, the equivalent of this step would have been redeploying the old codebase.

The cost of reversibility is that the old route is **not deleted**. Counting the migration as
finished happens by permanently turning the switch on and deleting the old rule; until then,
double maintenance continues. The two costs are two faces of the same coin.

```sh
# reconcile.sh — the same change is also carried to the old codebase; the two paths match again
sed -i.bak 's/export const DAILY_RATE = 2;/export const DAILY_RATE = 5;/' monolith/fee.mjs
rm -f monolith/fee.mjs.bak
echo "file touched: 2 (monolith/fee.mjs, fee-service.mjs)"
NEW_FEE=8797 node monolith/app.mjs 3
node monolith/app.mjs 3
kill "$(cat fee.pid)" 2>/dev/null
```

```
file touched: 2 (monolith/fee.mjs, fee-service.mjs)
loan issued: book 1 -> member 4, 3 days late, fee 15 (fee route: new)
loan issued: book 1 -> member 4, 3 days late, fee 15 (fee route: old)
```

## Three Columns

| | Gradual migration |
|---|---|
| Cheapens | Rollback is 1 step, redeploy 0; the cost of the new form is tested on a single route, the whole system does not move |
| Makes expensive | The same rule sits in 2 codebases; every change is written to 2 files; the router is an extra layer of indirection |
| Failure mode created | The same request gives two different results when only one codebase is updated; no process crashes, and each path is correct on its own terms |

## Summary

- The strangler fig migration moves a single step of a workflow to a new unit, leaves the
  rest in place, and ties traffic to a routing switch.
- The new unit is a separate deployment unit, so it has to carry the rule in its own
  codebase; the measurement shows the same two lines sitting identical in two codebases.
- The measure of double maintenance is that a rate change has to be written to 2 files; when
  it was written to only one, the same request gave two different results, 15 and 6.
- Rollback is 1 step and requires no redeploy, because the old route is still standing; the
  cost of this payoff is that double maintenance continues.
- The migration ends when the switch is turned on permanently and the old rule is deleted; a
  migration left half-finished means permanent double maintenance.

## Next Step

Across this topic the same loan workflow was built in five forms — monolith, modular
monolith, service-oriented arrangement, microservices, serverless functions — and the sixth
lesson measured the cost of moving to one of them. Every measurement changed some number:
import edges, boundary violations, units coupled to the shared model, processes, endpoints,
network hops, store calls, files touched.

All of these measurements shared one assumption that was never questioned: **where the
boundary runs** was taken as given. Catalog, membership, loans, notification, and pricing
were drawn as five separate modules as early as the first lesson; the next five lessons
inherited that line as it was and only argued over which form it would take. But the real
decision is not the form, it is where the line sits. Why is pricing not inside membership?
Why is notification separate from loans?

This question has not been tied to any criterion so far. The next topic takes it on directly:
what evidence, visible in the code and in the run, decides where a boundary should run.
