← Concepts & practices
Concept Data and persistence

Transactions and atomicity

Related changes should become visible together.

A payment transfer is two writes: debit the buyer, credit the seller. If the process stops between them, the database can contain a state that no completed transfer intended. A transaction gives the group one publication boundary: all of the changes commit, or none of them do.

The idea to keep

Atomicity is about the visibility of a group of changes: define the unit, do the work inside its boundary, and verify that a failure leaves no partial commit.

TypeScriptGo Make failure leave a state you can explain.
Start with one transfer

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.

State contractTransfer 30¢ · buyer → seller

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.
Watch the guarantee do its work

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.

Split writeslive state → debit → failure

One write has already escaped. A later repair is a separate operation with its own failure modes.

Transactionworking state → debit → credit → commit

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.

Hold the failure fixed

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.

State transition lab

What remains after the transfer fails?

Keep the transfer fixed at 30¢. Change the write boundary or the point of failure.

Current setupTwo visible writes

Debit and credit mutate the live balances one after another. The process fails after the buyer is debited.

Evidence appears after a run.

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.

Separate the two comparisons

Hold the transfer steady. Change the language.

The two writes use the same transfer but differ in where state becomes visible.

TypeScriptReading
ledger.ts · writes
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
	};
}
GoAlongside
ledger.go · writes
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

Know what the idea does not promise

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.

ReactAlready in your code
TaskBoard.tsx
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>
	);
}
Practice the decision

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?

Decision point

The process fails after the buyer is debited.

What change addresses the failure you can actually observe?

Leave a state note

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 →