A failure between writes is a real state.
The buyer has 100¢ and the seller has 40¢. A transfer moves 30¢ from buyer to seller. The intended result is 70¢ and 70¢, with the total still 140¢.
Without a transaction, the debit and credit are separate observations. A failure after the debit leaves the buyer at 70¢ while the seller remains at 40¢. The money did not “temporarily disappear” from the state that other readers can observe; the partial write is the current state until something repairs it.
The total balance should stay 140¢ and a failed transfer should publish neither write.
- Before
- Buyer 100¢ · seller 40¢
- Successful after
- Buyer 70¢ · seller 70¢
- Failure after debit
- Atomic result: 100¢ / 40¢. Split-write result: 70¢ / 40¢.
- Boundary
- Commit both writes together, or discard the working state.
The same failure should produce a different persisted state.
Hold the transfer and failure point fixed. The only change is where the writes run. A working state lets the transaction attempt the debit without making that debit visible; commit is the final publication step.
One write has already escaped. A later repair is a separate operation with its own failure modes.
The attempted writes are discarded if the boundary does not reach commit.
Read the invariant after each outcomeA check that makes partial state visible
Compare the total balance before and after. A successful transfer preserves 140¢. A transaction that rolls back also preserves 140¢. A split-write failure after the debit exposes 110¢, which is evidence that the group was not published atomically.
Run the same transfer through two boundaries.
The lab runs the displayed TypeScript model. Start with split writes and a failure after the debit. Then switch only the write boundary to a transaction. Inspect attempted versus persisted events: an attempted debit inside a transaction is not the same thing as a committed debit.
What remains after the transfer fails?
Keep the transfer fixed at 30¢. Change the write boundary or the point of failure.
Debit and credit mutate the live balances one after another. The process fails after the buyer is debited.
Start with split writes and a failure after the debit, then switch to a transaction without changing the transfer.
This lab models one process and two balances. It shows all-or-nothing state publication; it does not prove isolation from concurrent transfers, durable storage after a power loss, or atomicity across independent services.
What a rollback means hereDiscarding work before publication
This model represents rollback by throwing away the working copy. A database transaction may use locks, logs, snapshots, or other internal mechanisms; the application-level lesson is the observable contract that related writes either become visible together or do not.
Hold the transfer steady. Change the language.
The two writes use the same transfer but differ in where state becomes visible.
function applyTransfer(
balances: Balances,
failure: FailurePoint,
attemptedEvents: LedgerEvent[]
): void {
if (failure === 'before-debit') throw new Error('validation failed before the first write');
balances.buyer -= transferCents;
attemptedEvents.push({ action: 'debit', wallet: 'buyer', cents: transferCents });
if (failure === 'after-debit') throw new Error('process failed after the debit');
balances.seller += transferCents;
attemptedEvents.push({ action: 'credit', wallet: 'seller', cents: transferCents });
if (failure === 'after-credit') throw new Error('process failed after the credit');
}
export function runTransfer(strategy: Strategy, failure: FailurePoint = 'none'): TransferResult {
const before = copyBalances(initialBalances);
const working = copyBalances(before);
const attemptedEvents: LedgerEvent[] = [];
let committed = false;
let rolledBack = false;
let error: string | undefined;
try {
applyTransfer(working, failure, attemptedEvents);
committed = true;
} catch (caught) {
error = caught instanceof Error ? caught.message : 'unknown failure';
rolledBack = strategy === 'transaction' && attemptedEvents.length > 0;
}
const after =
strategy === 'transaction' && !committed ? copyBalances(before) : copyBalances(working);
const persistedEvents = committed
? attemptedEvents
: strategy === 'transaction'
? []
: attemptedEvents;
return {
strategy,
failure,
committed,
rolledBack,
before,
after,
attemptedEvents,
persistedEvents,
error
};
} func applyTransfer(balances Balances, failure FailurePoint, attemptedEvents *[]LedgerEvent) error {
if failure == BeforeDebit {
return fmt.Errorf("validation failed before the first write")
}
balances[Buyer] -= transferCents
*attemptedEvents = append(*attemptedEvents, LedgerEvent{Action: "debit", Wallet: Buyer, Cents: transferCents})
if failure == AfterDebit {
return fmt.Errorf("process failed after the debit")
}
balances[Seller] += transferCents
*attemptedEvents = append(*attemptedEvents, LedgerEvent{Action: "credit", Wallet: Seller, Cents: transferCents})
if failure == AfterCredit {
return fmt.Errorf("process failed after the credit")
}
return nil
}
func RunTransfer(strategy Strategy, failure FailurePoint) TransferResult {
before := copyBalances(initialBalances)
working := copyBalances(before)
attemptedEvents := make([]LedgerEvent, 0, 2)
committed := false
rolledBack := false
var transferError string
if err := applyTransfer(working, failure, &attemptedEvents); err != nil {
transferError = err.Error()
rolledBack = strategy == Transaction && len(attemptedEvents) > 0
} else {
committed = true
}
after := copyBalances(working)
if strategy == Transaction && !committed {
after = copyBalances(before)
}
persistedEvents := attemptedEvents
if strategy == Transaction && !committed {
persistedEvents = []LedgerEvent{}
}
return TransferResult{
Strategy: strategy,
Failure: failure,
Committed: committed,
RolledBack: rolledBack,
Before: before,
After: after,
AttemptedEvents: attemptedEvents,
PersistedEvents: persistedEvents,
Error: transferError,
}
} Both examples preserve the same state contract. The basic form applies the writes; the practical form places the call behind a commit decision; the caller exposes success or failure instead of asking readers to infer it from balances.
Copy the complete examplesStandard library only
These files model the boundary without pretending to be a database adapter. Replace the working copy with your database transaction API while preserving the same failure cases and assertions.
TypeScriptnode --experimental-strip-types ledger.ts
Gogo run ledger.go
Atomicity is one persistence guarantee.
This lesson establishes all-or-nothing publication for two related writes in one modeled boundary. It does not establish isolation from concurrent readers or writers, durability after a crash, consistency of every business rule, idempotency when a request is retried, or atomicity across independent services.
Use the next persistence lessons for neighboring questions: isolation asks what overlapping transactions can observe; optimistic concurrency detects conflicting versions; an outbox or workflow can coordinate work that cannot share one database transaction.
Build UIs?The same all-or-nothing move happens in component state.
Where it already is in your components
A task board that moves a card from To do to Done changes two lists. The usual component
code already treats them as one unit: it builds the next board from a copy and publishes
it with one setBoard call or one assignment. If building the next board throws,
nothing was published, so the card never sits in both columns or in neither.
When you have to own it
Now the move shows before the server answers. The component has to keep the snapshot it replaced, and when the save fails, restore the whole board rather than one column. The application still chooses the unit: no framework can infer that the two lists belong together, just as a database cannot infer which writes form one transfer.
A task board builds the next board from a copy and publishes it with one update, so a failure before that update publishes nothing.
import { useState } from 'react';
type Board = { todo: string[]; done: string[] };
// Build the next board from a copy, then publish it with one setBoard call.
// If anything throws before that call, the rendered board is untouched.
function complete(board: Board, card: string): Board {
if (!board.todo.includes(card)) throw new Error(`${card} is not in To do`);
return {
todo: board.todo.filter((id) => id !== card),
done: [...board.done, card]
};
}
export function TaskBoard({ initial }: { initial: Board }) {
const [board, setBoard] = useState(initial);
return (
<ul>
{board.todo.map((card) => (
<li key={card}>
{card}
<button type="button" onClick={() => setBoard(complete(board, card))}>
Done
</button>
</li>
))}
</ul>
);
}
Choose the change the failure calls for.
The observed failure is a partial balance after the debit. Which next move addresses the boundary rather than merely changing the timing around it?
The process fails after the buyer is debited.
What change addresses the failure you can actually observe?
Make the next failure legible.
Record the grouped writes, the state before the transaction, the injected or observed failure point, the committed state, and the invariant you checked. Keep a regression case that fails between the writes so a refactor cannot quietly split the boundary again.
- Why
- A failure between the debit and the credit must not leave a balance no transfer intended.
- What
- Debit buyer and credit seller inside one transaction; commit both or discard the working state.
- Constraint
- Total balance stays 140¢, a failed transfer persists neither write, and no slow network call runs inside the boundary.
- Fallback
- After a rollback, retry, ask for correction, or report failure. Work that cannot share one transaction goes through an outbox or workflow.
- Reconsider when
- The unit spans a second service, concurrent writers need isolation, or retries need idempotency.
A mental-model note to adapt to your own persistence boundary. Nothing here is saved to an account.
Explore more concepts & practices →