---
title: 'Working With Legacy Systems'
source: 'https://academia.sh/en/courses/enterprise-context/working-with-legacy-systems'
course: 'Enterprise Context and Integration'
language: en
updated: '2026-08-23T07:01:06+00:00'
license: 'CC BY-SA 4.0'
---

# Working With Legacy Systems

Wrapping a record system whose source code is unreachable, and the phased handoff of its ownership: reading the system's surface from a 60,000-call trace rather than from code, a wrapper that fully closes the interface closing 71.8 percent of calls while leaving seven call pairs and four owners outside, the coordination steps in the ownership handoff dropping from 24 to 13, and the co-written window never getting shorter than the slowest owner's release cycle.

The previous lesson's measurements all assumed the source systems could be read: their fields
were known, and when their schemas changed it showed up as an error message. In the regional
library network's fictional model there is a system that does not meet this assumption. A record
system that has held loan history for years is still running, and the network's oldest records
sit in it, but no one can reach its source code: no field can be added, no behavior can be
changed, and no one can ask what is happening inside.

This lesson does not measure how the migration is carried out path by path. Its question comes
earlier: what can be built around an unchangeable system, what remains that thing cannot close
off, and how long the two sides write together while the data the system holds moves to a
different owner. The network is a **fictional model** whose owners and budgets are separate.

## The Unreachable System's Surface Is Read From Traffic

A system's call surface is usually derived from its code. Here there is no code, so the measure
has to come from somewhere else: the **calls arriving** at the system are traced, and the
surface is counted from that trace. The surface's unit is not a single operation name — it is the
**(caller, operation) pair**; two systems calling the same operation are two separate edges,
because the two have separate owners.

**IN15: the calling systems, their owners, paths, and call shares are fictional.** **IN16: the
legacy system's surface cannot be read from code; the measure is a 60,000-call trace observed
over ninety days.** **IN17: the wrapper can only close operations on the call interface; work
that enters the database directly and transfers that arrive through the file directory cannot be
wrapped.**

```js
// legacy/model.mjs — fictional model of a record system whose source code is unreachable. Its surface
// is measured from traffic, not code: the call trace is actually generated and the next blocks scan it.
export const SEED = 4127, DAYS = 90, EVENTS = 60_000, START = 30;
// Each owner has its own release cycle (days) and hands off exactly one writer per cycle (IN18).
export const CYCLE = { wrap: 7, loan: 7, branch: 14, fee: 14, membership: 21, data: 30, management: 30 };

// Calling systems: their owner, the path they reach the legacy system by, and the operations they
// use. The "interface" path can be wrapped; the "database" and "file" paths stay outside the wrapper.
export const CALLERS = {
  "branch-front": { owner: "branch", path: "interface", share: 34, operations: ["open-loan", "accept-return", "read-history"] },
  "loan-service": { owner: "loan", path: "interface", share: 30, operations: ["open-loan", "accept-return", "extend", "write-fine"] },
  membership: { owner: "membership", path: "interface", share: 8, operations: ["match-member", "read-history"] },
  "night-job": { owner: "data", path: "database", share: 12, operations: ["batch-close", "archive"] },
  "fee-accounting": { owner: "fee", path: "database", share: 9, operations: ["write-fine", "read-history"] },
  "reporting-tool": { owner: "management", path: "database", share: 5, operations: ["read-history"] },
  "branch-transfer": { owner: "branch", path: "file", share: 2, operations: ["batch-close", "archive"] },
};

// Each operation touches one data entity and either reads or writes it.
export const OPERATIONS = {
  "open-loan": ["loan", "write"], "accept-return": ["loan", "write"], extend: ["loan", "write"],
  "batch-close": ["loan", "write"], "write-fine": ["fine", "write"], "match-member": ["member-link", "write"],
  archive: ["history", "write"], "read-history": ["history", "read"],
};
export const ENTITIES = ["loan", "fine", "member-link", "history"];

// Call trace: each event carries a caller, an operation, and a day.
export function generateTrace(t = SEED) {
  let s = t;
  const r = () => (s = (s * 48271) % 2147483647) / 2147483647;
  const names = Object.keys(CALLERS);
  const total = names.reduce((a, k) => a + CALLERS[k].share, 0);
  const trace = [];
  for (let i = 0; i < EVENTS; i += 1) {
    let p = r() * total, caller = names[0];
    for (const k of names) { p -= CALLERS[k].share; if (p <= 0) { caller = k; break; } }
    const operation = CALLERS[caller].operations[Math.floor(r() * CALLERS[caller].operations.length)];
    const [entity, direction] = OPERATIONS[operation];
    trace.push({ caller, operation, entity, direction, path: CALLERS[caller].path, day: Math.floor(r() * DAYS) });
  }
  return trace;
}

// The wrapper can only close operations reached over the interface path.
export const closable = (e, scope) => e.path === "interface" && scope.includes(e.operation);
export const SCOPE = {
  none: [],
  narrow: ["open-loan", "accept-return"],
  "full-interface": ["open-loan", "accept-return", "extend", "write-fine", "match-member", "read-history"],
};
```

## What the Wrapper Closes and What It Cannot

The wrapper is a layer placed in front of the legacy system that routes every call through
itself. What it can close depends on which path the caller arrives by, and that choice of path
is a decision the caller's owner made years ago.

```js
// legacy/surface.mjs — the call surface and the share the wrapper closes, counted from the trace
import { generateTrace, CALLERS, SCOPE, closable, EVENTS } from "./model.mjs";

const trace = generateTrace();
const surface = (d) => new Set(d.map((e) => `${e.caller}|${e.operation}`)).size;

console.log(`trace: ${trace.length} calls, ${Object.keys(CALLERS).length} caller systems, ` +
  `${surface(trace)} distinct (caller, operation) pairs\n`);
console.log("path          callers surface   calls   share owners");
console.log("------------- ------- ------- ------- ------- ------");
for (const path of ["interface", "database", "file"]) {
  const events = trace.filter((e) => e.path === path);
  const callers = Object.entries(CALLERS).filter(([, v]) => v.path === path);
  console.log(`${path.padEnd(13)} ${String(callers.length).padStart(7)} ${String(surface(events)).padStart(7)} ` +
    `${String(events.length).padStart(7)} ${`${(events.length / EVENTS * 100).toFixed(1)}%`.padStart(7)} ` +
    `${String(new Set(callers.map(([, v]) => v.owner)).size).padStart(6)}`);
}

console.log("\nwrapper scope    closed surface  closed calls    share  outside surface  outside calls  outside owners");
console.log("--------------- --------------- ------------- -------- ---------------- -------------- ---------------");
for (const [name, scope] of Object.entries(SCOPE)) {
  const closed = trace.filter((e) => closable(e, scope)), outside = trace.filter((e) => closable(e, scope) === false);
  const outsideOwners = new Set(outside.map((e) => CALLERS[e.caller].owner)).size;
  console.log(`${name.padEnd(15)} ${String(surface(closed)).padStart(15)} ${String(closed.length).padStart(13)} ` +
    `${`${(closed.length / EVENTS * 100).toFixed(1)}%`.padStart(8)} ${String(surface(outside)).padStart(16)} ` +
    `${String(outside.length).padStart(14)} ${String(outsideOwners).padStart(15)}`);
}
```

```
trace: 60000 calls, 7 caller systems, 16 distinct (caller, operation) pairs

path          callers surface   calls   share owners
------------- ------- ------- ------- ------- ------
interface           3       9   43073   71.8%      3
database            3       5   15697   26.2%      3
file                1       2    1230    2.1%      1

wrapper scope    closed surface  closed calls    share  outside surface  outside calls  outside owners
--------------- --------------- ------------- -------- ---------------- -------------- ---------------
none                          0             0     0.0%               16          60000               6
narrow                        4         22516    37.5%               12          37484               6
full-interface                9         43073    71.8%                7          16927               4
```

The first table shows that a system whose source code is unreachable can still be measured.
There are seven caller systems, sixteen distinct (caller, operation) pairs, and three separate
paths. 71.8 percent of calls come through the call interface; the rest either enter the database
directly or arrive through a file directory. The file path, small as a number — 2.1 percent —
carries two separate surface pairs, and one of them **writes** data.

The second table gives the wrapper's limit. Even when its scope covers every operation on the
interface, the closed share stops at 71.8 percent: seven surface pairs and 16,927 calls stay
outside, because those calls never touch the interface at all. A wrapper closes a system's
**door**; it does not close the holes in its wall. Since there is no access to the source code,
the only way to close those holes is to talk to whoever opened them — the caller's owner.

The **owners** column left outside says a second thing. The narrow-scope wrapper closes 37.5
percent of calls but does not lower the outside owner count from six at all. The share of closed
calls does not mean coordination has dropped; what lowers coordination is **all** of an owner's
paths closing. The wrapper that fully closes the interface brings six owners down to four.

## Ownership Handoff and the Co-Written Window

The wrapper is a preparation; the actual work is the handoff of ownership of the data entities
the legacy system holds to a new system. An entity's ownership does not move at a single moment:
every path that writes to it is converted one at a time, and until all of them are converted,
two systems write to it at once. This **window** can be measured.

**IN18: each owner has its own release cycle and hands off exactly one writer per cycle.**
**IN19: every writer left outside needs a notification cycle and a verification cycle (two
steps); every writer behind the wrapper is handed off with a single redirect (one step).**

```js
// legacy/handoff.mjs — data ownership handoff: the window in which two systems write at once,
// and the coordination steps across owners. Every number is read from the same call trace.
import { generateTrace, CALLERS, SCOPE, closable, ENTITIES, START, CYCLE } from "./model.mjs";

const trace = generateTrace();

function handoff(entity, scope) {
  const isWriter = (e) => e.entity === entity && e.direction === "write";
  const writers = [...new Set(trace.filter(isWriter).map((e) => `${e.caller}|${e.operation}`))];
  const cutover = new Map(), counter = {}, outside = [];
  for (const writer of writers) {
    const [caller, operation] = writer.split("|");
    if (closable({ path: CALLERS[caller].path, operation }, scope)) { cutover.set(writer, CYCLE.wrap); continue; }
    const owner = CALLERS[caller].owner;
    counter[owner] = (counter[owner] ?? 0) + 1;
    cutover.set(writer, counter[owner] * CYCLE[owner]);
    outside.push(owner);
  }
  const coWritten = trace.filter((e) => isWriter(e) && e.day >= START
    && e.day < START + cutover.get(`${e.caller}|${e.operation}`)).length;
  return { writerCount: writers.length, insideCount: writers.length - outside.length, outsideCount: outside.length,
    ownerCount: new Set(outside).size, steps: (writers.length > outside.length ? 1 : 0) + 2 * outside.length,
    window: Math.max(...cutover.values()), coWritten };
}

console.log("entity       scope            writers  in wrap  outside  owners  coord steps  window (days)   co-written");
console.log("------------ --------------- -------- -------- -------- ------- ------------ -------------- ------------");
const totals = {};
for (const entity of ENTITIES) for (const name of ["none", "full-interface"]) {
  const d = handoff(entity, SCOPE[name]);
  totals[name] = { steps: (totals[name]?.steps ?? 0) + d.steps, coWritten: (totals[name]?.coWritten ?? 0) + d.coWritten,
    window: Math.max(totals[name]?.window ?? 0, d.window) };
  console.log(`${entity.padEnd(12)} ${name.padEnd(15)} ${String(d.writerCount).padStart(8)} ${String(d.insideCount).padStart(8)} ` +
    `${String(d.outsideCount).padStart(8)} ${String(d.ownerCount).padStart(7)} ${String(d.steps).padStart(12)} ` +
    `${String(d.window).padStart(14)} ${String(d.coWritten).padStart(12)}`);
}
for (const [name, t] of Object.entries(totals))
  console.log(`total (${name}): ${t.steps} coordination steps, longest window ${t.window} days, ` +
    `${t.coWritten} calls written to both systems`);
```

```
entity       scope            writers  in wrap  outside  owners  coord steps  window (days)   co-written
------------ --------------- -------- -------- -------- ------- ------------ -------------- ------------
loan         none                   7        0        7       3           14             42         6862
loan         full-interface         7        5        2       2            5             30         3386
fine         none                   2        0        2       2            4             14          781
fine         full-interface         2        1        1       1            3             14          781
member-link  none                   1        0        1       1            2             21          556
member-link  full-interface         1        1        0       0            1              7          173
history      none                   2        0        2       2            4             30         1307
history      full-interface         2        0        2       2            4             30         1307
total (none): 24 coordination steps, longest window 42 days, 9506 calls written to both systems
total (full-interface): 13 coordination steps, longest window 30 days, 5647 calls written to both systems
```

What the wrapper gains is not concentrated in a single column of the table. Coordination steps
drop from 24 to 13, co-written calls from 9,506 to 5,647. The longest window falls from 42 days
to 30 — but it does not go below thirty.

The four rows give four separate results, and what makes the difference is who writes to the
entity. Seven paths write to loan records; five of them go through the interface, so they are
handed off behind the wrapper with a single redirect, and the remaining two wait for their own
owners' cycles: coordination drops from 14 steps to 5, the window from 42 days to 30. The member
link has a single writer, and it goes through the interface; with the wrapper the window drops
from 21 days to 7 and co-written calls from 556 to 173. In the fine records, the wrapper gains
one step but does not shorten the window at all, because the window is set by the writer outside.
In the history records, the two rows are identical: both paths that write to that entity are
outside the interface, so the wrapper changes nothing in this handoff.

The limit that comes out of this is this lesson's real measure. The wrapper does not set the
window's lower bound; **the slowest owner's release cycle** does. The owner that runs the night
job has a thirty-day cycle, and that owner writes to the history records directly from the
database. No matter how wide the wrapper is, it cannot close that path, so thirty days will be
paid. A decision that wants to speed up the migration should look first not at the wrapper's
scope, but at the owners of the paths left outside and their cycles.

There is also a warning in the co-written calls column. Over the course of the window, 5,647
calls are written to both systems at once; these calls have to be correct on both sides, and
each one means two write operations. The length of the window is not a calendar problem — it is
the number of records double-written over that calendar.

## Summary

- The call surface of a system whose source code is unreachable is measured from a trace, not
  from code: a 60,000-call trace shows 7 callers, 16 distinct (caller, operation) pairs, and
  three separate paths (IN15, IN16).
- The wrapper that fully closes the interface closes 71.8 percent of calls; 7 surface pairs and
  16,927 calls stay outside, because the paths entering through the database and the file
  directory cannot be wrapped (IN17).
- The share of closed calls does not show that coordination has dropped: the narrow-scope wrapper
  closes 37.5 percent of calls yet does not lower the outside owner count from six. An owner drops
  off the list only once all of its paths are closed.
- In the ownership handoff, the wrapper brings coordination steps down from 24 to 13, and
  co-written calls from 9,506 to 5,647; in the member link the window drops from 21 days to 7,
  while in the history records the two cases stay identical (IN18, IN19).
- The window's lower bound is set not by the wrapper's scope but by the slowest owner's release
  cycle: because the owner with a thirty-day cycle writes to the database directly, the longest
  window can only fall from 42 days to 30.

## Next Step

This lesson worked with what was already there: the system stands, it cannot be changed, a layer
is built around it, and its ownership is handed off step by step. The decision itself had already
been made — that system was once either bought or written in-house, and every cost paid today is
a continuation of that decision. The course's last lesson tries to measure that decision. Two
options are modeled for one capability: buying and building. Total cost of ownership is added up
item by item — adaptation, integration edges, release upgrades, training, and exit — and the
winner is shown to change with the time horizon. A second number is added next to it: the risk
that a path whose owner is someone else gets cut, that is, dependency risk.
