01 / The prompt
“Inventory is becoming its own service. Build it and move the shop onto it.”
The shop from the last five lessons already did the hard part of the code: Orders reaches Inventory through its front door, and Inventory keeps its tables in a database of its own. So an agent builds the service, writes a client, and adds a switch: local or service. It works, and every test passes, because tests run in one mode or the other.
The shop does not stop selling while you move. Copy the stock on Monday, flip the switch on Tuesday, and the service starts from Monday’s numbers. Take the service down for a minute and a helpful fallback sells from the old store, which the new owner never hears about. Flip back after a bad day, and the old store has never seen the day’s sales. In the lesson’s example, a move that copies and flips a flag oversells or loses a sale in 6 of 8 moves; the staged move, in 0.
The ticket asked for a service. It never asked the question: at each moment of the move, which store owns the stock, and how does the other one catch up before it takes over?
02 / Name the shape
A move is a handover of ownership, done in steps you can check and undo.
Extracting a service means one module’s responsibility, and its data, move to a process of their own while the rest keeps running. The seam is already there when the module has a front door. What the move adds is a sequence: copy the data, keep the copy current, prove the two agree, switch the owner, and keep a way back that brings the new owner’s changes home.
Stock has exactly one owner at every moment. The service takes over only when its copy matches; the shop takes back only after copying in what the service changed. When the owner does not answer, the answer is “unavailable”, never the other store.
Who answers, and who owns, at each stage of the staged move:
| Stage | Answers and owns | The other store |
|---|---|---|
| Local | The shop’s store | The service is empty, or a stale copy. |
| Shadow | The shop’s store | The service gets every change too, so its copy keeps up. Its answers are ignored; the cutover compares the whole stores. |
| Service | The service | The shop’s store is frozen. Nothing writes to it, including a fallback. |
| Back to local | The shop’s store, after copying the service’s | The service’s store is the source of the copy, then idle. |
Words to put in a prompt or a review
- Seam
- The one interface callers use, so switching the adapter behind it changes no caller.
- Data handoff
- Copying the module’s data to the new owner, and proving the copy matches.
- Shadow
- Sending every change to the new owner too, while the old one still answers.
- Cutover
- The moment ownership switches. It should be a check that can refuse, not a flag.
- Split brain
- Two stores both taking writes, each unaware of the other’s.
- Reconciled rollback
- Going back only after the old owner has everything the new one changed.
Where this sits among the migration lessonsThree moves, one idea
Strangler fig migration moves a system one route at a time; Branch by abstraction swaps an implementation behind an interface while shipping from main. This lesson is the step both of them hide when the thing being moved owns data: who owns it during the move, and how the copy stays true.
03 / Two moves, same steps
Copy and flip, or copy, shadow, check, and switch. Where does a mug get sold twice?
Both columns run the lesson’s own move code on the same steps, with five mugs in stock. The highlighted store is the owner. Watch four moves, then open Try it and run one yourself.
Move Inventory out, two ways
Copy and flip a flag
local owns stock
shop · INVENTORY_DB
mug 5
no holds
service
mug 5
no holds
→ copied
Copy, shadow, check, then switch
local owns stock
shop · INVENTORY_DB
mug 5
no holds
service
mug 5
no holds
→ copied
Step 1 of 5: Copy the local store to the service.
Two mugs sell after the copy. The flip never tells the service, which still thinks it has five, and sells four more. The staged move replayed the sale to the copy, so the service says out of stock.
Reduced motion: choose a scene to see its completed state.
Read this scene
Two mugs sell after the copy. The flip never tells the service, which still thinks it has five, and sells four more. The staged move replayed the sale to the copy, so the service says out of stock.
Copy and flip a flag: copied. Local mug stock 5, service mug stock 5, owner local.
Copy, shadow, check, then switch: copied. Local mug stock 5, service mug stock 5, owner local.
Watch restarts the story when you come back. Step through shows where each chapter ends. Try it starts a new shop whenever you change the plan or press Reset.
04 / Read the shape
A port, a move that keeps the copy current, and switches that can say no.
Basic form is the seam, with the remote adapter that can fail to answer. In the wild is the staged move while it runs. At the call site is where an operator switches owners. Notice that neither switch is a bare assignment.
The seam: one port Orders calls, a local adapter over the shop’s store, and a remote adapter that speaks JSON to the service and reports no answer as unavailable.
/**
* The seam. Orders reserves stock through this and nothing else, so the move
* changes which adapter answers, not the code that calls it. "unavailable" is
* part of the contract from the start: a remote owner can fail to answer.
*/
export interface InventoryPort {
hold(ref: string, sku: string, qty: number): HoldStatus | 'unavailable';
release(ref: string): 'released' | 'unavailable';
}
/** Today's owner: Inventory's rules over the shop's own store. */
export class LocalInventory implements InventoryPort {
store: Store;
constructor(store: Store) {
this.store = store;
}
hold(ref: string, sku: string, qty: number) {
return hold(this.store, ref, sku, qty);
}
release(ref: string) {
release(this.store, ref);
return 'released' as const;
}
}
/** Tomorrow's owner: the same calls as JSON to the service, and no answer when it is down. */
export class RemoteInventory implements InventoryPort {
private readonly service: InventoryService;
constructor(service: InventoryService) {
this.service = service;
}
send<T>(request: object): T | 'unavailable' {
try {
return JSON.parse(this.service.handle(JSON.stringify(request))) as T;
} catch (error) {
if (error instanceof ServiceUnreachable) return 'unavailable';
throw error;
}
}
hold(ref: string, sku: string, qty: number) {
const reply = this.send<{ status: HoldStatus }>({ op: 'hold', ref, sku, qty });
return reply === 'unavailable' ? reply : reply.status;
}
release(ref: string) {
return this.send({ op: 'release', ref }) === 'unavailable' ? 'unavailable' : 'released';
}
/** Replaces the service's store with a copy of `store`. */
load(store: Store): boolean {
return this.send({ op: 'import', store: copyStore(store) }) !== 'unavailable';
}
/** The service's whole store, or null when it does not answer. */
dump(): Store | null {
const reply = this.send<{ store: Store }>({ op: 'export' });
return reply === 'unavailable' ? null : reply.store;
}
} // InventoryPort is the seam. Orders reserves stock through this and nothing
// else, so the move changes which adapter answers, not the code that calls it.
// "unavailable" is part of the contract from the start: a remote owner can
// fail to answer.
type InventoryPort interface {
Hold(ref, sku string, qty int) string // held, out-of-stock, or unavailable
Release(ref string) string // released or unavailable
}
// LocalInventory is today's owner: Inventory's rules over the shop's own store.
type LocalInventory struct{ Store Store }
func (l *LocalInventory) Hold(ref, sku string, qty int) string {
return l.Store.HoldStock(ref, sku, qty)
}
func (l *LocalInventory) Release(ref string) string { l.Store.Release(ref); return "released" }
// RemoteInventory is tomorrow's owner: the same calls as JSON to the service,
// and no answer when it is down.
type RemoteInventory struct{ Service *InventoryService }
func (r *RemoteInventory) send(req request, reply any) bool {
body, _ := json.Marshal(req)
out, err := r.Service.Handle(body)
if errors.Is(err, ErrUnreachable) {
return false
}
if err != nil {
panic(err)
}
if err := json.Unmarshal(out, reply); err != nil {
panic(err)
}
return true
}
func (r *RemoteInventory) Hold(ref, sku string, qty int) string {
var reply struct{ Status string }
if !r.send(request{Op: "hold", Ref: ref, Sku: sku, Qty: qty}, &reply) {
return "unavailable"
}
return reply.Status
}
func (r *RemoteInventory) Release(ref string) string {
var reply struct{ Status string }
if !r.send(request{Op: "release", Ref: ref}, &reply) {
return "unavailable"
}
return "released"
}
// Load replaces the service's store with a copy of s.
func (r *RemoteInventory) Load(s Store) bool {
var reply struct{ Status string }
c := copyStore(s)
return r.send(request{Op: "import", Store: &c}, &reply)
}
// Dump returns the service's whole store, or false when it does not answer.
func (r *RemoteInventory) Dump() (Store, bool) {
var reply struct{ Store Store }
ok := r.send(request{Op: "export"}, &reply)
return reply.Store, ok
} The flip, in fullWhat a straight move produces
Copy once, flip, fall back to the local store when the service does not answer, flip back to undo. Every line is reasonable on its own. Each one gives stock a second owner at some moment of the move.
/** The big-bang move: copy once, flip a flag, fall back when the service fails, flip back to undo. */
export class FlagFlip extends Move {
copy(): Reply {
return this.remote.load(this.local.store)
? { status: 'copied', matches: null }
: { status: 'unreachable' };
}
shadow(): Reply {
return { status: 'skipped' };
}
cutover(): Reply {
this.stage = 'service';
return { status: 'cut-over' };
}
rollback(): Reply {
this.stage = 'local';
return { status: 'rolled-back', reconciled: false };
}
hold(ref: string, sku: string, qty: number) {
const answer = this.owner().hold(ref, sku, qty);
// "Keep selling if the service is down": the local store takes the hold.
return answer === 'unavailable' ? this.local.hold(ref, sku, qty) : answer;
}
} The auditEvery sale on record with the owner, nothing oversold
import { seed, type Store } from './inventory.ts';
import type { Move, Stage } from './move.ts';
// Every hold the shop was told succeeded must be in the store that owns stock
// now, and no sku may be promised more units than it started with.
export type Acknowledged = { ref: string; sku: string; qty: number };
export type Outcome = {
stage: Stage;
local: Store;
service: Store;
violations: string[];
};
export function audit(move: Move, acknowledged: Acknowledged[]): Outcome {
const authority = move.authority();
const violations: string[] = [];
for (const hold of acknowledged)
if (!authority.holds.some((h) => h.ref === hold.ref))
violations.push(`hold-missing:${hold.ref}`);
for (const [sku, start] of Object.entries(seed)) {
const promised = acknowledged.filter((h) => h.sku === sku).reduce((sum, h) => sum + h.qty, 0);
const extra = authority.stock[sku] + promised - start;
if (extra > 0) violations.push(`oversold:${sku}:${extra}`);
}
return {
stage: move.stage,
local: move.local.store,
service: move.service.store,
violations
};
} The behavior these examples promiseChecked by 16 shared scenarios
- A quiet move, and the same hold sent twice, end consistent under both plans.
- A sale between the copy and the cutover, the service down after the cutover, a rollback after sales on the service, and a cutover with stores that differ each leave the flip with a sale the owner never recorded and mugs promised twice; the staged move ends consistent in every one.
- The staged move refuses a cutover while the stores differ and a rollback while the service does not answer, and stays with the current owner.
Every expectation was generated by a separate model written from the contract in the examples’ README, not copied from either implementation, and it is kept beside the examples.
Reading the TypeScriptA service behind a string
InventoryService.handle takes and returns JSON strings, so nothing but data
crosses between the shop and the service, as it would over HTTP. RemoteInventory catches ServiceUnreachable and turns it into the port’s 'unavailable'.
Both plans extend one Move class, so the audit reads the owner the same way for
each.
Reading the GoAn interface with two structs
Move is an interface embedding InventoryPort; FlagFlip and StagedMove each embed one moveState. The service returns ErrUnreachable when down, and replies are maps so the printed JSON matches the
shared cases exactly.
Run it yourselfNo dependencies
Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later), or go run . in the Go folder. Both print:
flip · sale before cutover: copied skipped held cut-over held -> hold-missing:r1, oversold:mug:2 staged · sale before cutover: copied shadowing held cut-over out-of-stock -> consistent flip · service down: copied skipped cut-over down held up held -> hold-missing:r1, oversold:mug:2 staged · service down: copied shadowing cut-over down unavailable up held -> consistent flip · roll back: copied skipped cut-over held rolled-back held -> hold-missing:r1, oversold:mug:2 staged · roll back: copied shadowing cut-over held rolled-back out-of-stock -> consistent
05 / Review the agent’s diff
“If the service does not answer, hold the stock locally. Checkout keeps working.”
It fixes last night’s outage. Read what it does to who owns the stock.
06 / How it fails
A move fails quietly: every reply looks fine, and the stock is wrong a day later.
Each row except the last is a shared scenario the tests run.
| What goes wrong | What a customer sees | Copy and flip | Staged move |
|---|---|---|---|
| A sale lands between the copy and the cutover | A confirmed order for a mug that is not there | The service starts from the old copy and sells those units again. | The shadow replayed the sale; the service says out of stock. |
| The service is down after the cutover | Flip: a normal checkout. Staged: “we cannot confirm stock right now” | The fallback sells from the frozen local store; the owner never hears of it. | “Unavailable”, and nothing written. Retrying with the same ref is safe. |
| The move is rolled back after a day of sales | Later, orders for stock that was already sold | The local store has never seen the day’s sales. | The service’s store is copied back before the switch. |
| The rollback is needed while the service is down | Staged: “unavailable” until the service answers | Flips back, and loses the service’s sales. | Refuses. Staying on a down owner is an outage; switching is data loss. |
| The cutover runs before the copy caught up | Nothing yet | Cuts over to a stale store. | Compares, refuses, stays local. |
| The same hold is sent twice | Nothing | Both keep one hold: the ref makes the call safe to repeat. | |
| The service is slow, not down | A long checkout | Without a timeout, waits forever. | Not modeled in the example. Give every call a timeout and treat it as unavailable. |
Timeouts and retries have their own lessons: Timeouts, deadlines, and races and Idempotency and at-least-once.
07 / Is it worth it?
A staged move takes longer than a flag. What does the extra week buy?
| Change | Inventory in the shop | Inventory as a service |
|---|---|---|
| A second client: the warehouse app needs stock | It needs the shop’s database, or a new endpoint on the shop. | It calls the service like the shop does. |
| Replacing Inventory’s database | A change inside the module either way; the port hides it. | The same, and deployed on its own. |
| A new rule: holds expire after 15 minutes | One code change. | One code change, in the service. No difference worth a move. |
| Another team takes over Inventory | They share the shop’s deploys and its on-call. | They release on their own, and answer for the service. |
The service earns its place with the first and last rows, not the middle two. And the move itself has costs: a network hop on every hold, a new “unavailable” answer the shop must handle, a second thing to run. Whether the move is worth it is Modular monolith vs. services. How to move once you decided is this lesson.
Decide what a good move looks like before starting, and measure against today:
- Shadow mismatches: holds where the service’s answer differed from the shop’s. The cutover needs zero over a period you choose, such as a week of normal traffic.
- Hold latency at the 95th percentile, in-process today and over the network in shadow. This is the cost of the hop, measured before anyone depends on it.
- Unavailable answers per day after the cutover, and their checkouts.
- Audit findings after the cutover and after any rollback: sales missing from the owner, and units promised twice. The target is zero.
This lesson did not measure a real shop, and gives no numbers.
08 / Ask for it
One starting point, one ticket, two prompts.
Two agents running Claude Sonnet each got a copy of the shop as the Consistency run left it, and the same ticket: an Inventory service on another machine with its own database, a switch between local and service mode, and two scripts that move a stopped shop onto the service and back without losing its orders. One prompt added an Extraction block: one owner at a time, no fallback to the local store, a checked handover in both directions, timeouts, and safe retries. A script moved each build with real orders, sold on the service, moved it back, and put its own proxy between the shop and the service to make it hang or stop answering.
| Question | Plain prompt | Extraction prompt |
|---|---|---|
| Two orders in local mode, then to-service.ts | Exit 0; mug 10, cap 1 on the service | Exit 0; mug 10, cap 1 on the service |
| Three mugs sold on the service, then to-local.ts | Exit 0; mug 7 back in local mode, and the last mug refused | Exit 0; mug 7 back in local mode, and the last mug refused |
| The local store while the service owned stock | Untouched | Untouched |
| The service stops answering mid-checkout | 500 “internal-error” after 16 ms | No reply within 60 s |
| … and then | Nothing charged; after a restart, 1 order and 1 charge | Charged and placed once the service answered; after a restart, 1 order and 1 charge |
| The service hangs mid-checkout | 500 after 5 s | No reply within 60 s |
| … and then | Nothing charged; after a restart, 1 order and 1 charge | Charged and placed once the service answered; after a restart, 1 order and 1 charge |
| Stopped after the provider charged, in service mode | 1 order, 1 charge after the restart | 1 order, 1 charge after the restart |
| Its own tests | 109 of 109 pass | 100 of 100 pass |
The move itself went right in both builds. Both scripts carried every sku and every reservation across and back, the three mugs sold on the service were still sold after the return, and neither build wrote to the local store while the service owned stock. Neither added a fallback. What the extraction block added to the move is a comparison: its scripts check every sku and every open reservation after copying and refuse to finish on a mismatch, where the plain scripts print what they copied. The plain ticket already said the shop was stopped during the move and that the orders must survive, and that did most of the work the story in section 03 shows going wrong.
The difference is what a customer hears when the new owner fails. The plain build answers 500 “internal-error” and leaves the checkout it recorded pending. Its own comment says the next startup will resume it. It does: after a restart, the card was charged and the order placed, for a customer who was told it failed.
+ // createOrder() now reaches inventory (in-process, or over HTTP to the
+ // inventory service — see MOVE.md). A network failure there is a new
+ // failure mode this endpoint's documented responses never had to name
+ // before, so it's caught here rather than left to crash the process:
+ // the checkout row this attempt wrote (see CONSISTENCY.md) is left
+ // exactly where it was (nothing committed past it), so the next
+ // request or the next startup's recovery sweep resumes it safely once
+ // inventory is reachable again.
+ let result;
+ try {
+ result = await createOrder(parsed);
+ } catch (err) {
+ console.error("createOrder failed:", err);
+ sendJson(res, 500, { error: "internal-error" });
+ return;
+ } The extraction build never tells a customer something false, because it tells them nothing: it retries the reservation until the service answers, and the request got no reply within a minute. When the service came back, the order went through. This is the same choice the Consistency run made about the card provider, now made about Inventory: an unknown outcome is not a failure, and nobody said how long a customer should wait for one.
+async function resolveReservation(checkoutKey: string, items: StockItem[]): Promise<ResolvedReservation> {
+ let attempt = 0;
+ for (;;) {
+ const result = await reserveStock(checkoutKey, items);
+ if (result.ok === true) {
+ return { ok: true, reservationId: result.reservationId };
+ }
+ if (result.ok === false) {
+ return { ok: false };
+ }
+ // result.ok === null: unknown (timeout / network-error) — never a
+ // decision on its own.
+ attempt += 1;
+ const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** attempt, RETRY_MAX_DELAY_MS);
+ await sleep(delay);
+ }
+} So the line the runs showed was missing is about the answer, not the move: when the owner does not answer, reply within N seconds that the order is pending, and make the recovery that finishes it later tell the customer. The prompt in section 10 includes it.
How the runs were made and checkedTwo builds, recorded as written
- The starting point is the Consistency lesson’s recorded consistency build, byte for byte, with its checksums checked before the runs. Both agents were launched at the same time; neither was told about the other, the lesson, or the checker.
- Both agents stalled once, during their manual checks, when the session’s watchdog stopped them after ten quiet minutes. Each was resumed once with a message to continue the same task.
- The checker restores each build into a fresh folder with its own three database files, runs its own mail and payment providers, starts the build’s service, and sends the shop’s service calls through a proxy that can hold or drop them. The move scripts run with the shop stopped, as the ticket said. After each failure it waits 12 seconds, restarts the shop, and reads orders, charges, and stock.
- The checker ran three times. The first ran its scripts with a synchronous spawn, which froze the checker’s own proxy and providers, so every script timed out; the second fixed that; the third added the restart after each failure. All three are kept.
- Both agents wrote scratch files in
/tmp; the extraction agent deleted all of its own, and one file the plain agent left there was removed after the run. Neither stopped a process by name. - One run of each prompt is a sample, not a measurement of the model.
09 / Hold it there
The move ends, and the rules it needed stay. Three checks keep them.
The HTTP client’s door: a timeout is not the default
The rule “a slow service is unavailable” depends on a timeout that nobody sets by accident. Go’s
http.Clientdocumentation says so directly: “A Timeout of zero means no timeout.” (net/http, Client) In the browser and Node,fetchwaits until the connection gives up;AbortSignal.timeout()“returns an AbortSignal that will automatically abort after a specified time.” (MDN, AbortSignal.timeout()) Both quotes were checked on 26 September 2026. Ask for the timeout by number.A rule that keeps one door
Only Orders’ inventory client may reach the service, and in service mode nothing imports Inventory’s local store. That is an import rule of the kind Enforcement layer runs on every change, and it is what stops the next fallback from being one import away.
The audit, after every switch
Run the store comparison and the audit after the cutover and after any rollback, against the real stores, and treat a finding as a failed move. The scripts that switch owners should run it themselves and refuse to finish.
There is no frontend row here. A backend move does not change a component; the one thing a page sees is a new “unavailable” answer, and showing that honestly is the pending state in Consistency without a shared transaction.
10 / Make the call
Move the owner in checked steps, and treat the way back as part of the move.
If you can close the shop for the move, a copy, a comparison, and a switch are enough; the shadow exists because a live system keeps changing while you copy it. When it cannot close, shadow until the stores agree for a period you chose in advance, cut over only on a passing comparison, never fall back to the old store, and make the rollback copy the new owner’s changes home. Reopen the plan if the service will share the old database; that is not a move of ownership, and the lesson’s risks become a different set.
Take it with you
Explain it without saying “extraction”: “We are handing the stock book to another team. Until they have a copy that matches ours line for line, we keep writing in ours and send them every change. After the handover, only their book counts, even when they are slow to answer. If we take it back, we take their pages too.” Then find the switch in your own code that picks between two implementations, and ask what the second one knows about the first one’s writes.
Paste into your next prompt, and fill in the blanks
[Inventory] is moving to its own service. Stock has one owner at a time: [the local database] until the cutover, the service after it. Never write stock to both, and never fall back to the local store when the service is slow or down; that call's outcome is unknown, and the caller says so. Hand the data over with a check: copy, keep the copy current by replaying every change until the cutover, compare both stores (every total, every open reservation), and refuse to switch if they differ. Going back is part of the move: before the local store owns stock again, copy in everything the service changed, with the same check. If the service cannot answer, stay on it. Every call from the shop to the service has a timeout, and every call that changes stock carries a key so repeating it is safe. When the service does not answer, the customer hears within [5] seconds that the order is pending, never that it failed, and whatever finishes it later tells them. Write the move and the way back as scripts with a runbook, and add tests that sell during the move, stop the service mid-checkout, roll back after sales, and check the stock.
Connections to follow nextRelated lessons
- Consistency without a shared transaction left the build this lesson’s runs started from.
- Modular monolith vs. services is whether to move at all.
- Module contracts shaped the front door that became the seam.
- Strangler fig migration moves a whole system route by route.
- Branch by abstraction swaps an implementation behind an interface while shipping.