← Architecture
Decompose a system Where a record’s authority lives

Who writes this data?

One record, one writer.

Somewhere in your system an order’s status is set from more than one place: the payment webhook, the shipping screen, the support tool. Each one is right about its own moment. Let’s build a small marketplace, cancel one order at the wrong time, and see who wins.

TypeScriptGoOne marketplace, two ways to write an order, a scan, and four recorded builds.

01 / The prompt

“Build me the order backend for a bike marketplace.”

You ask for orders, a payment webhook, labels, a courier scan, delivery, and cancellation, with a folder for each team that handles them. What comes back works. When we asked, the plain request returned a build where every endpoint answered and a normal sale went from placed to delivered.

It also returned an order that four places write: the store when it is created, and Payments, Shipping, and Support, each setting the status it needs. Nothing in the prompt said who may change an order, so every team may.

Then a buyer cancels while the courier is on the way. The question the prompt never answered is who may change this order, and what may happen to it next? That question is also a way to find where a module is missing.

02 / Name the shape

One record, one writer.

Data ownership means every record has exactly one part of the system allowed to change it. Others may read it, and they may ask for a change, but the owner decides whether that change is allowed from where the record is now.

Ask who may change each record. Put that code in one module, and make everyone else ask it.

It is also a way to find boundaries. If three teams write an order’s status, the order has no owner, and the rules about what may happen to an order are spread across three folders. The question “who writes this?” shows where a module is missing.

Who owns each record in the marketplace
RecordOwnerWhat others do instead of writing it
An order: status, charged, refunded, handed overorders/Payments asks markPaid, Shipping markShipped, Support cancel. Orders may refuse.
A shipment labelshipping/Nobody else prints or voids one.
The payment itselfThe payment providerPayments records what the provider reports; a refused payment is the signal to send it back.

Words to put in a prompt or a review

Owner
The one module allowed to change a record.
Single writer
A record written from one place, however many places read it.
Transition
An allowed move from one status to another, such as paid to shipped.
Blind write
Setting a value without checking what it was: the last writer wins.
Lost update
A change that happened and was then overwritten by someone who did not see it.
Refusal
The owner saying no, with the current status, so the caller can react.
Reading is not writingMany readers are fine; many writers are the problem

Shipping checks that an order is paid before it prints a label, and that is fine: a read cannot break the order. The trouble is a write based on an old read. Shipping read “paid” when it printed the label, and wrote “shipped” when the courier came, and in between the order was cancelled. An owner turns that into one question asked at the moment of the write.

Optimistic concurrency covers the other half, two writers of the same kind racing each other, and Making illegal states unrepresentable covers the transition table as a type.

03 / Follow one order

Watch a refunded bike leave with the courier.

A buyer pays for a 1990s road bike, the seller prints a label, and the buyer cancels while the courier is on the way. First with every team writing the status, then with Orders as the only writer. In Try it, put the events in any order you like.

Decompose a system

Who may change this order?

Three writers

order-7 · a 1990s road bike

status
placed
charged
$0
refunded
$0
handed over
no

Writes orders.status: checkout, payments, shipping, support

Events

  1. Order placed
01/ 04
Four writers

Who writes an order’s status?

The scan finds four modules writing orders.status: checkout, payments, shipping, support. Each writes the value it needs, when it needs it.

Reduced motion: choose a scene to see its completed state.

Read this scene

The scan finds four modules writing orders.status: checkout, payments, shipping, support. Each writes the value it needs, when it needs it.

Three writers. Status placed, charged 0 dollars, refunded 0 dollars, handed over no. Events: order placed.

Watch restarts the story when you come back. Step through keeps your step. Try it replays a fresh order each time you press the button.

04 / Read the shape

The owner holds the list of allowed moves.

Basic form is the owner’s rule: a table of allowed transitions and a function that refuses the rest. In the wild is the scan that finds every write in a codebase, so you can see which modules write which fields. At the call site the scan becomes a check that fails when a module writes a table it does not own.

The owner’s rule: an order moves only along the allowed transitions, and a refused move changes nothing. apply also runs the blind version, where each event writes the status it wants, so the two designs can replay the same events.

TypeScriptReading
ownership.ts
export type Status = 'placed' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
export type Order = {
	status: Status;
	priceCents: number;
	chargedCents: number;
	refundedCents: number;
	handedOver: boolean;
};
export type Event = 'pay' | 'label' | 'cancel' | 'handover' | 'deliver';

export const allowed: Record<Status, Status[]> = {
	placed: ['paid', 'cancelled'],
	paid: ['shipped', 'cancelled'],
	shipped: ['delivered'],
	delivered: [],
	cancelled: []
};

/** One order and its shipment label, as the owner sees them after each event. */
export type State = { order: Order; label: boolean; log: string[] };

export function place(priceCents: number): State {
	return {
		order: { status: 'placed', priceCents, chargedCents: 0, refundedCents: 0, handedOver: false },
		label: false,
		log: []
	};
}

const target: Record<Exclude<Event, 'label'>, Status> = {
	pay: 'paid',
	cancel: 'cancelled',
	handover: 'shipped',
	deliver: 'delivered'
};

/**
 * Apply an event. With `owned`, the order moves only along `allowed` and a refused event
 * changes nothing. Without it, each module writes the status it wants, as a blind write.
 */
export function apply(state: State, event: Event, owned: boolean): State {
	const order = { ...state.order };
	let label = state.label;
	let outcome: string;
	if (event === 'label') {
		// Both designs print a label only for a paid order: that check is a read.
		label = order.status === 'paid' || label;
		outcome = order.status === 'paid' ? 'label printed' : `no label: order is ${order.status}`;
	} else if (event === 'handover' && !label) {
		outcome = 'no label';
	} else {
		const to = target[event];
		if (owned && !allowed[order.status].includes(to)) {
			outcome = `refused: ${order.status} cannot become ${to}`;
		} else {
			order.status = to;
			if (event === 'pay') order.chargedCents = order.priceCents;
			if (event === 'cancel') order.refundedCents = order.chargedCents;
			if (event === 'handover') order.handedOver = true;
			outcome = to;
		}
	}
	return { order, label, log: [...state.log, `${event}: ${outcome}`] };
}

/** The marketplace's promise: a bike never leaves the seller after its money went back. */
export const broken = (order: Order) => order.handedOver && order.refundedCents > 0;
GoAlongside
main.go
type Status string

type Order struct {
	Status        Status `json:"status"`
	PriceCents    int    `json:"priceCents"`
	ChargedCents  int    `json:"chargedCents"`
	RefundedCents int    `json:"refundedCents"`
	HandedOver    bool   `json:"handedOver"`
}

// Allowed lists where each status may go next.
var Allowed = map[Status][]Status{
	"placed":    {"cancelled", "paid"},
	"paid":      {"cancelled", "shipped"},
	"shipped":   {"delivered"},
	"delivered": {},
	"cancelled": {},
}

// State is one order and its shipment label after each event.
type State struct {
	Order Order    `json:"order"`
	Label bool     `json:"label"`
	Log   []string `json:"log"`
}

func Place(priceCents int) State {
	return State{Order: Order{Status: "placed", PriceCents: priceCents}, Log: []string{}}
}

var target = map[string]Status{"pay": "paid", "cancel": "cancelled", "handover": "shipped", "deliver": "delivered"}

// Apply runs one event. With owned, the order moves only along Allowed and a
// refused event changes nothing. Without it, each module writes the status it
// wants, as a blind write.
func Apply(s State, event string, owned bool) State {
	order, label := s.Order, s.Label
	var outcome string
	switch {
	case event == "label":
		if order.Status == "paid" {
			label = true
			outcome = "label printed"
		} else {
			outcome = "no label: order is " + string(order.Status)
		}
	case event == "handover" && !label:
		outcome = "no label"
	default:
		to := target[event]
		if owned && !slices.Contains(Allowed[order.Status], to) {
			outcome = fmt.Sprintf("refused: %s cannot become %s", order.Status, to)
			break
		}
		order.Status = to
		switch event {
		case "pay":
			order.ChargedCents = order.PriceCents
		case "cancel":
			order.RefundedCents = order.ChargedCents
		case "handover":
			order.HandedOver = true
		}
		outcome = string(to)
	}
	return State{order, label, append(slices.Clone(s.Log), event+": "+outcome)}
}

// Broken is the marketplace's promise, failed: a bike left after its money went back.
func Broken(o Order) bool { return o.HandedOver && o.RefundedCents > 0 }
The two marketplaces, where the status is writtenThree writers, against one owner

With three writers, Shipping and Support each set the status they need:

three writers · shipping/index.ts
/** The courier scans the label: the bike has left the seller. */
export function handOver(db: Store, orderId: string): string {
	if (!db.get('shipments', orderId)) return 'no label';
	db.update('orders', orderId, { status: 'shipped', handedOver: true });
	return 'shipped';
}
three writers · support/index.ts
/** A support agent cancels the order and refunds whatever was charged. */
export function cancelOrder(db: Store, orderId: string): string {
	const order = db.get('orders', orderId);
	if (!order) return 'no such order';
	db.update('orders', orderId, { status: 'cancelled', refundedCents: order.chargedCents });
	return 'cancelled';
}

With one writer, Orders holds the allowed moves, and Shipping asks:

one writer · orders/index.ts
const allowed: Record<Status, Status[]> = {
	placed: ['paid', 'cancelled'],
	paid: ['shipped', 'cancelled'],
	shipped: ['delivered'],
	delivered: [],
	cancelled: []
};

export function createOrders(db: Store) {
	function move(orderId: string, to: Status, extra: Record<string, number | boolean> = {}) {
		const order = db.get('orders', orderId);
		if (!order) return 'no such order';
		const from = order.status as Status;
		if (!allowed[from].includes(to)) return `refused: ${from} cannot become ${to}`;
		db.update('orders', orderId, { status: to, ...extra });
		return to;
	}
one writer · shipping/index.ts
export function handOver(orders: Orders, db: Store, orderId: string): string {
	if (!db.get('shipments', orderId)) return 'no label';
	return orders.markShipped(orderId);
}
The behavior these examples promiseChecked by shared cases from a separate model
  • An order starts placed. Allowed moves: placed to paid or cancelled; paid to shipped or cancelled; shipped to delivered. Delivered and cancelled are final.
  • Paying charges the price, canceling refunds what was charged, and the courier’s scan sets handed over. A label is printed only for a paid order, and a scan without a label does nothing. Those are reads, the same in both designs.
  • With one writer, a move that is not allowed is refused and changes nothing. With blind writes, every event writes its status.
  • The promise: a bike is never handed over after money was refunded.
  • The scan finds .insert('table', {…}) and .update('table', id, {…}) outside comments and strings, with a literal table name, and reads the keys of the object literal and any string value.

Every expectation in cases.json was produced by a small Python model written from these rules, which replays the events and scans the same repositories. It lives beside the examples in model/cases.py. Both marketplaces are also run event by event and must match the model’s replay line for line.

Reading the TypeScriptA record of allowed moves

allowed is a Record<Status, Status[]>, so adding a status without saying where it may go is a type error. apply returns a new state instead of changing the old one, which lets the story and the lab keep every step.

Reading the GoNo backreferences, and pointer values

Go’s regexp has no backreferences, so the table name is matched as two alternatives, one per quote style. A field’s literal value is a *string, so “no literal” and “the empty string” stay different, the way null does in the shared cases.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types ownership.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/data-ownership

go 1.23
three writers: pay: paid, label: label printed, cancel: cancelled, handover: shipped
  shipped, refunded 900, handed over true (broken)
one writer: pay: paid, label: label printed, cancel: cancelled, handover: refused: cancelled cannot become shipped
  cancelled, refunded 900, handed over false

05 / Review the agent’s diff

“Shipping now checks for a cancelled order.”

The bug report is exact, and so is the fix. Before you merge it, ask which other module can still write the status, and whether this check is the rule or a copy of it.

The agent’s pull request

“Fixed the bike that shipped after a refund: Shipping now checks for a cancelled order before handing over. Added a test for the reported sequence. All tests pass.”

// shipping/index.ts
			export function handOver(db: Store, orderId: string): string {
			  if (!db.get('shipments', orderId)) return 'no label';
			(added)  if (db.get('orders', orderId)?.status === 'cancelled') return 'cancelled';
			  db.update('orders', orderId, { status: 'shipped', handedOver: true });
			  return 'shipped';
			}
			
You are reviewing this change. What do you do?

06 / How it fails

Many writers fail by each being right about its own moment.

Every write in the three-writer marketplace is correct when you read it alone. The failures come from their order.

How an order with many writers fails, and what one owner does
What goes wrongThree writersOne writer
Conflicting: cancelled while the courier is comingshipped, refunded, and handed over. Shared case.handover: refused: cancelled cannot become shipped. Shared case.
Duplicated and late: a payment webhook after the cancelThe cancel is overwritten; the bike ends shipped. Shared case.pay: refused: cancelled cannot become paid. Payments must refund. Shared case.
Half-done: cancelled after the bike leftcancelled, refunded, and the bike is with the buyer. Shared case.cancel: refused: shipped cannot become cancelled. Support has to start a return instead. Shared case.
No difference: cancelled before the labelcancelled; no label is printed. Shared case.cancelled; the same. Shared case.
Slow to change: a new statusEvery writer of the status has to learn it: checkout, payments, shipping, support.One table in orders/. Scan.
Unreachable ownerNothing to reach; every module writes.If Orders is a separate service and down, nobody can change an order. Authored.

The last row is the price of an owner: it has to be there. In one deployment that is a function call. Once Orders becomes its own service it is a network call, and Communication between modules covers what happens when it is slow or gone.

07 / Is it worth it?

You pay in asking. Here is what it buys.

Three writers is less code: each team sets the status it needs. One owner means Payments, Shipping, and Support call Orders and handle a refusal. Run both against the same four kinds of change.

The same four changes, made to each marketplace
ChangeThree writersOne writer
A second entry point: a seller app that marks bikes handed overA fourth writer of the status, with its own idea of when it is allowed.It calls markShipped and gets the same answers.
Replace a dependency: a new payment providerA change in Payments.A change in Payments. No difference: the provider is Payments’ own business.
Change a rule: returns after deliveryNew statuses written by whichever teams need them, and every other writer checked.New entries in allowed, and new asks.
A second team takes over ordersThere is no orders code to take over; the rules are in three folders.They get orders/ and its table.

Before moving writes behind an owner, decide what you will measure:

  • Writers per record, from the scan in section 04, on every pull request. This is the baseline; the target is one.
  • Orders in states the business says are impossible, such as handed over and refunded, counted from the table itself before and after.
  • Refusals by reason, after the change. A refusal is a race the old design lost silently; a count shows how often it happens.

This lesson measured two small marketplaces and four recorded builds, not traffic, so it has no before-and-after numbers for a real one.

08 / Ask for it

Two prompts, one ticket, eight sequences.

We sent two agents running Claude Sonnet the marketplace request, with a folder per team. The architecture prompt added an Architecture block: an orders/ folder owns the order, nobody else writes it, the others ask, and Orders decides what is allowed. Then each build got the same ticket, returns and failed deliveries, from a fresh agent. A script sent every build the same sequences of events over HTTP.

What the checker found, run 2026-09-23. Amounts in cents.
QuestionPlain promptArchitecture prompt
Code that writes an order’s statusstore.ts, payments/, shipping/, support/orders/
Cancelled while the courier is on the wayshipped, charged 90000, refunded 90000, handed over true (bike left after a refund)cancelled, charged 90000, refunded 90000, handed over false
A payment webhook after the cancelshipped, charged 90000, refunded 0, handed over truecancelled, charged 0, refunded 0, handed over false
Cancelled after the bike leftcancelled, charged 90000, refunded 90000, handed over true (bike left after a refund)cancelled, charged 90000, refunded 90000, handed over true (bike left after a refund)
The returns ticket changedserver.ts +22 −3, shipping/handlers.ts +48 −0, support/handlers.ts +23 −0, types.ts +4 −1OWNERSHIP.md +21 −9, orders/index.ts +58 −3, server.ts +33 −12, shipping/index.ts +17 −1, support/index.ts +13 −4
Code that writes the status, after itstore.ts, payments/, shipping/, support/orders/
A return on a cancelled ordercancelled, charged 90000, refunded 90000, handed over falsecancelled, charged 90000, refunded 90000, handed over false
A failed delivery, then delivereddelivered, charged 90000, refunded 90000, handed over true (bike left after a refund)undeliverable, charged 90000, refunded 90000, handed over true
A failed delivery after a cancelcancelled, charged 90000, refunded 90000, handed over falsecancelled, charged 90000, refunded 90000, handed over false

The plain build shipped a refunded bike. Every team wrote the status it needed, and Shipping checked only that a label existed:

shipping/handlers.ts · plain prompt
if (!order.hasLabel) {
  sendJson(res, 409, { error: "order has no shipping label" });
  return;
}

order.status = "shipped";
order.handedOver = true;

After the ticket it had eight places writing the status, and a new way to lose: the courier fails to deliver, the buyer is refunded, and a later “delivered” is accepted, so the buyer has the bike and the money. The architecture build refused that, and the cancel during delivery, and the late payment webhook.

It still broke the promise once. Its owner allowed a cancel from “shipped”, and wrote that down:

OWNERSHIP.md · architecture prompt
## Allowed status transitions (enforced inside `orders/index.ts`)

```
placed --(markPaid)--> paid --(printLabel, same status)--> paid[labelPrinted]
paid[labelPrinted] --(markShipped)--> shipped --(markDelivered)--> delivered

placed | paid | shipped --(cancelOrder)--> cancelled
```

So a cancel after the courier took the bike refunds the buyer in both builds. The difference is where the fix goes: one line of that table, against a check in every team that writes the status. An owner makes the rule one place. It does not make the rule right; you still have to say what may happen next.

One more thing the table shows: when the late payment webhook was refused, the order recorded charged 0, and nothing sent the money back. The refusal came back as a 409 to the payment provider. The prompt line these runs point to is the one both prompts lacked: list the allowed moves, and say what the caller does when the owner refuses: a late payment is refunded, a cancel after handover becomes a return.

How the runs were made and checkedFour builds, recorded as written
  • The first plain agent stalled before writing server.ts and was stopped by the harness after ten minutes without progress. A fresh agent got the identical prompt; its build is the one checked. Each ticket went to a fresh agent working on a copy.
  • The files each agent wrote are kept byte for byte, with checksums. The checker restores them, finds status writes by reading every build, counts the ticket’s diff, and starts a fresh server for every sequence.
  • The checker’s first run counted a parameter typed status: number as a write, and flagged an undeliverable order, whose bike is on its way back, as broken. Both were fixed, and both runs are kept.
  • Four agents wrote, or tried to write, a log or a response body outside their folders. Two of those writes landed, one in /tmp and one in the session’s scratch folder; the others aimed at the filesystem root and failed. Every agent stopped its server by process id.
  • This is one sample of each prompt, not a measurement of a model.

09 / Hold it there

Make the owner the only one who can write.

An owner is a convention until something stops the next direct write. Three kinds of check, from the database up.

  1. The database’s own door

    In PostgreSQL, “the initial state is that only the owner (or a superuser) can do anything with the object. To allow other roles to use it, privileges must be granted” (Privileges). Give each module its own database role, make Orders’ role the owner of the orders table, and grant the others only SELECT. A direct write from Shipping then fails at the database. This lesson did not run these grants; it keeps its data in memory.

    -- Each module connects as its own role. Orders owns the orders table.
    ALTER TABLE orders OWNER TO orders_module;
    GRANT SELECT ON orders TO shipping_module, support_module, payments_module;
    -- Shipping owns its shipments; nobody else writes them.
    ALTER TABLE shipments OWNER TO shipping_module;
  2. A rule a check enforces

    An import rule cannot tell which table a store handle writes, so the check is the scan from section 04, run as a test. Over the three-writer marketplace it reports 5 writes by modules that do not own the table; over the one-writer marketplace, none. The rule lives with the others in Architecture as rules.

    ownership.spec.ts
    // ownership.spec.ts
    import { expect, it } from 'vitest';
    import { checkSingleWriter } from './ownership';
    import { readRepo } from './read-repo';
    
    it('lets only the owner write each table', () => {
    	expect(checkSingleWriter(readRepo('src'), { orders: 'orders', shipments: 'shipping' })).toEqual([]);
    });
  3. A check on what actually happens

    The promise itself is checkable: no order is ever handed over and refunded. Count those rows in production, and replay recorded event sequences against the owner in tests, the way this lesson’s specs run every order of four events and find the promise broken only with blind writes.

This idea runs where records are stored. In a UI, the same question is state ownership, which has its own lesson, State ownership in a component tree; this lesson has no frontend row.

10 / Make the call

Give a record an owner when more than one part changes it.

A record that only one feature writes already has an owner, even without a folder named after it. Leave it alone. A log line, a counter, or a cache entry that nobody makes decisions from can take blind writes too.

Give it an owner when two or more parts of the system write it, and especially when the order of those writes matters: statuses, balances, stock, anything with a rule about what may come next. Reopen the decision when the owner becomes a bottleneck, and look at the transitions it guards before splitting it.

Take it with you

Explain it without saying “data ownership”: “Only Orders changes an order. Everyone else asks, and Orders says yes or no depending on where the order is now.” Then search your own code for every place that sets an order’s status, or whatever your most important record is. How many folders is that?

Paste into your next prompt, and fill in the blanks

Give each record one owning module: <orders/> owns the order.
No other module writes it, directly or through a shared store handle.
Other modules ask the owner for a change in their own words (<mark paid>,
<mark shipped>, <cancel>), and the owner decides from the current status
whether it is allowed. A refused change returns <409> naming the status,
and the caller handles it (<refund a late payment>, <stop the courier>).
Write the owner of each record and its allowed changes in OWNERSHIP.md,
and add a test that fails when another module writes the record.
Connections to follow nextRelated lessons

Take the marketplace into your editor. Add a dispute, opened by the buyer after delivery, and decide which module owns it before you write a status.

Back to architecture →