← Architecture
Events, queues, and workflows Facts on a log

Event-driven architecture

Record the sale. Let everyone else catch up.

Every “order placed” email you have sent was a reaction to something that already happened. The question is who makes it happen: the code that took the order, calling the mailer and waiting, or the mailer, finding out on its own. At a ticket release, where everyone buys in the same minute, that choice decides whether a slow text provider slows every sale.

TypeScriptGoOne release, a box office and four readers, two designs, two recorded builds.

01 / The prompt

“Build the ticket release: sell seats, keep a seat map and a report, text every buyer.”

A small venue puts 30 seats on sale at ten o’clock. The obvious handler sells the seat, then updates the seat map, adds to the sales report, and sends the confirmation text. It is correct, it is easy to test, and every sale now waits for the slowest thing it calls.

Release traffic is not normal traffic. When Ticketmaster opened the presale for Taylor Swift’s Eras Tour in November 2022, it reported “3.5 billion total system requests – 4x our previous peak” (Ticketmaster). Your venue is smaller, and its SMS provider is also having its busiest minute. In the lesson’s example, a provider that slows to three seconds makes every sale take 3004 ms, and one that goes down loses 2 buyers’ texts for good, while every sale is answered as sold.

The brief never asked the question: when a sale happens, who needs to know, and does the sale have to wait for them to hear?

02 / Name the shape

The box office records what happened. Everyone else reads it at their own pace.

In an event-driven design, the part that makes a decision records it as a fact, an event, and stops there. Other parts react by reading the facts. Here the box office decides a sale and appends SeatSold to a log in the same write as the sale. The seat map, the tally, and the texts are consumers: each reads the log from its own position, and a consumer added next month reads it from the start.

A sale is one write: the seat and its SeatSold event. Every reader keeps its own position in the log, moves it only after finishing an event, and can be slow, down, or new without the box office noticing.

Who owns what:

What each part of the release owns, and how it learns about a sale
PartOwnsLearns about a sale by
Box officeSeats and sales; the decision; the logMaking it
Seat mapWhich seats show as sold, and its positionReading SeatSold
Sales tallyCounts per section, and its positionReading SeatSold
TextsWhat was sent, and its positionReading SeatSold, then calling the SMS provider with the sale as key
A report added laterIts own counts, from position 0Reading the whole log, then keeping up

Words to put in a prompt or a review

Event
A fact in the past tense, such as SeatSold, with the data a reader needs.
Command
An instruction to one receiver, such as “send this text”. It expects someone to obey.
Log
An append-only, ordered list of events, each with an offset.
Consumer position
The offset a consumer has finished, kept by the consumer, not the log.
Lag
How far a consumer is behind the end of the log: the number to watch.
At-least-once
An event may reach a consumer twice, so every consumer must be safe to repeat.
An event, not a command in disguiseSeatSold versus SendConfirmationText

SendConfirmationText would be a command: it names one receiver and tells it what to do. The box office would still be deciding who reacts, only through a queue. SeatSold names nobody, carries the seat, the sale, and the phone number, and lets the texts consumer decide that a sale means a text. The difference shows the day a second reader arrives: a fact needs no change to the box office.

Inside one process, the same choice between calling and announcing is Communication between modules. This lesson is the durable version: a log with positions, that survives restarts and serves readers who arrive late.

03 / One release, two designs

Same buyers, same provider. What happens to a sale when a reader is slow?

Both columns run the lesson’s own venue code on the same steps, as one release: a normal opening, a slow SMS provider, a provider that goes down and comes back, and a report added halfway through. Watch, then open Try it and break the provider yourself.

Event-driven architecture

One release, two ways to spread the news

The box office calls everyone

Last sale took 304 ms SMS provider: up

  • Seat map 1 sold
  • Sales tally 1 counted
  • Texts 1 sent

The box office records SeatSold

Last sale took 2 ms SMS provider: up

Log: 1 SeatSold event

  • Seat map 0 sold 1 behind
  • Sales tally 0 counted 1 behind
  • Texts 0 sent 1 behind
01/ 04
The release opens

Step 1 of 4: Someone buys A1.

Three seats sell. Calling everyone, each sale waits for the seat map, the tally, and a 300 ms text. Recording SeatSold, each sale is one write; the readers catch up a moment later.

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

Read this scene

Three seats sell. Calling everyone, each sale waits for the seat map, the tally, and a 300 ms text. Recording SeatSold, each sale is one write; the readers catch up a moment later.

The box office calls everyone: sale took 304 ms; seat map 1, tally 1, texts 1.

The box office records SeatSold: sale took 2 ms; seat map 0, tally 0, texts 0, texts 1 behind.

Watch restarts the story when you come back. The chapters are one release: each continues from the last. Try it starts a new release whenever you change the design or press Reset.

04 / Read the shape

A sale that appends, readers that keep their place, and a key at the edge.

Basic form is the box office recording the fact. In the wild is a consumer catching up from its own position. At the call site is the one consumer that talks to another company. Notice what the box office no longer knows.

The box office decides the sale and appends SeatSold to the log in the same write. It calls nobody and knows nothing about who reads the log.

TypeScriptReading
venue.ts
/**
 * The box office records the decision as a fact, SeatSold, in an append-only
 * log, in the same write as the sale. It calls nobody. Whoever cares reads the
 * log from its own position.
 */
export class EventVenue extends Venue {
	readonly design = 'events';
	readonly log: SeatSold[] = [];
	readonly positions: Partial<Record<ConsumerName, number>> = { seatmap: 0, tally: 0, texts: 0 };
	crashTextsAfterSend = false;

	sell(seat: string, phone: string): Reply {
		const decided = this.decide(seat, phone);
		if (!('saleId' in decided)) return decided;
		this.log.push({ offset: this.log.length + 1, type: 'SeatSold', ...decided });
		return { status: 'sold', saleId: decided.saleId, ms: cost.write };
	}
GoAlongside
venue.go
// EventVenue: the box office records the decision as a fact, SeatSold, in an
// append-only log, in the same write as the sale. It calls nobody. Whoever
// cares reads the log from its own position.
type EventVenue struct {
	venueState
	Log                 []SeatSold
	Order               []string // consumers, in the order they catch up
	Positions           map[string]int
	CrashTextsAfterSend bool
}

func (v *EventVenue) Sell(seat, phone string) Reply {
	sale, refused := v.decide(seat, phone)
	if refused != nil {
		return refused
	}
	v.Log = append(v.Log, SeatSold{Offset: len(v.Log) + 1, Type: "SeatSold", Sale: sale})
	return Reply{"status": "sold", "saleId": sale.SaleID, "ms": costWrite}
}
The box office that calls everyoneThe first version, for comparison

The same decision, followed by every reader, inside the sale. A failed text is logged and dropped so the sale can succeed, which is the reasonable choice here, and the reason texts are lost.

venue.ts
/** The box office tells everyone itself, inside the sale. */
export class DirectVenue extends Venue {
	readonly design = 'direct';

	sell(seat: string, phone: string): Reply {
		const decided = this.decide(seat, phone);
		if (!('saleId' in decided)) return decided;
		let ms = cost.write;
		this.readers.apply('seatmap', decided);
		this.readers.apply('tally', decided);
		ms += cost.seatmap + cost.tally;
		ms += smsLatency[this.sms.state];
		// A failed text must not fail the sale, so the error is logged and dropped.
		this.sms.send(null, phone, seat);
		if (this.readers.attendance !== null) {
			this.readers.apply('attendance', decided);
			ms += cost.attendance;
		}
		return { status: 'sold', saleId: decided.saleId, ms };
	}

	addAttendance() {
		this.readers.attendance = 0; // it hears about sales from now on
	}
}
The behavior these examples promiseChecked by 14 shared scenarios
  • Both designs sell the same seats, refuse a sold seat and an unknown one, and end with the same seat map, tally, and texts when nothing fails.
  • Calling everyone, a sale costs the sum of its readers, three seconds when the provider is slow; recording the event, a sale costs one write whatever the provider does.
  • Calling everyone, texts sent while the provider is down are lost, and a report added later misses every earlier sale. Reading the log, the texts wait at their position and a new report counts everything.
  • A texts consumer that stops after sending and runs again sends one text per sale.

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 TypeScriptClasses over plain arrays

EventVenue.log is an array that only grows, and positions maps each consumer to the offset it has finished. pump() stands in for each consumer’s own loop; a real consumer would run on a timer or wake on each append. Milliseconds are counted from each step’s stated cost, not measured.

Reading the GoAn interface over two designs

Venue is an interface; DirectVenue and EventVenue embed the same state. Order keeps the consumers in a fixed order so the output matches exactly, since Go maps have none.

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:

direct · sms slow: sale ms 3004+3004, texts 2 -> every reader agrees
events · sms slow: sale ms 2+2, texts 2 -> every reader agrees
direct · sms down, then back: sale ms 5+5, texts 0 -> text-missing:s-1, text-missing:s-2
events · sms down, then back: sale ms 2+2, texts 2 -> every reader agrees
direct · report added late: sale ms 304+304+305, texts 3 -> attendance-off:-2
events · report added late: sale ms 2+2+2, texts 3 -> every reader agrees

05 / Review the agent’s diff

“Buyers want their text right away. I send it from the sale, with retries.”

The complaint is real. Read what the fix puts back into every sale.

The agent’s pull request

“Buyers were waiting up to a minute for their confirmation text during the pre-sale. I send the text right after the sale commits, with three retries, and keep the texts consumer as a fallback. All tests pass.”

// box-office/sell.ts
			db.transaction(() => {
			  sales.insert(sale);
			  log.append({ type: 'SeatSold', ...sale });
			});
			(added) // Buyers want their confirmation right away, not when the texts consumer gets to it.
			(added) for (let attempt = 1; attempt <= 3; attempt++) {
			(added)   try {
			(added)     await sms.send(sale.phone, `Seat ${sale.seat} is yours`);
			(added)     break;
			(added)   } catch {}
			(added) }
			return { status: 201, body: { saleId: sale.id, seat: sale.seat } };
			
The full release opens tomorrow at 10:00. What do you do with this change?

06 / How it fails

Calling everyone fails in the sale. Reading a log fails as lag, and lag can be watched.

Each row except the last is a shared scenario the tests run.

Failure modes of one ticket release
What goes wrongWhat a buyer seesCalling everyoneRecording SeatSold
The SMS provider is slowCalling: a three-second checkoutEvery sale waits for it, at the peak.Sales take one write; the texts lag, then catch up.
The SMS provider is downA sale, and no textThe error is logged and the text is gone.The texts consumer stops at its position and sends them when the provider is back.
A report is added mid-releaseNothingIt counts only sales from now on.It reads the log from the start and counts every sale.
The texts consumer stops after sending, before saving its positionNothingNot applicable: nothing resumes.It sees the event again; the sale’s key means the provider sends one text.
Two buyers want the same seatOne sale, one “taken”The box office decides before anything is recorded; readers only see the winner.
A consumer has a bug and must be rerunNothingThe data is gone; there is nothing to rerun.Fix it and move its position back. Not modeled in the example; it relies on every consumer being safe to repeat.

Retries and duplicates have their own lessons: Idempotency and at-least-once and Backpressure and queues.

07 / Is it worth it?

A log costs a table, a loop per reader, and answers that arrive a moment late.

The shared four changes, against calling everyone from the sale
ChangeCalling everyoneRecording SeatSold
A second entry point: the box-office desk sells at the doorThe desk has to call every reader too, or share the sale function.The desk appends the same event; readers do not change.
A new SMS providerChange the call in the sale.Change the call in the texts consumer. The same size of change.
A new rule: hold a seat for five minutes before it sellsA change in the box office.A change in the box office, and perhaps a new event. No difference worth a log.
The marketing team wants its own readerA change to the sale, reviewed by the team that owns it.A new consumer, reading from the start, deployed by marketing.

The costs: every reader is a moment behind, so a page that shows the seat map right after a sale can show the seat as free; every consumer needs a position, a loop, and code that is safe to repeat; and a log keeps growing. For a form that saves one row and sends one email a day, calling the mailer is fine.

Measure before you choose, and after:

  • Sale time at the 95th percentile during a release, with the provider’s own latency beside it. If they move together, the provider is in your sale.
  • Texts sent per sale, which should be exactly one, and never zero.
  • Lag per consumer, in events and seconds, and the target you would accept for each: seconds for the seat map, minutes for the report.

This lesson did not measure a real release, and gives no numbers of its own.

08 / Ask for it

One brief, two prompts.

Two agents running Claude Sonnet each got the brief from section 01, with fixed ports and a described SMS provider, in an empty folder. One prompt added an Events block: the sale and a SeatSold event in one transaction, the seat map, report, and texts as consumers with their own positions, facts rather than instructions, and at-least-once delivery that never counts or texts twice. A script ran both builds against its own SMS provider.

What the checker found, run 2026-09-23
QuestionPlain promptEvents prompt
Thirty buyers for one seat, at once1 sold, 29 taken1 sold, 29 taken
Five salesevery sale answered in 4 ms or less; 5 of 5 buyers textedevery sale answered in 4 ms or less; 5 of 5 buyers texted
The SMS provider takes three secondsevery sale answered in 5 ms or less; 3 of 3 buyers textedevery sale answered in 5 ms or less; 3 of 3 buyers texted
The provider down, back within seconds3 of 3 buyers texted3 of 3 buyers texted
The provider down for 40 seconds0 of 3 buyers texted (9 attempts)3 of 3 buyers texted (83 attempts)
A restart with three texts owed0 of 3 buyers texted3 of 3 buyers texted
The provider sends, then drops the connection3 of 3 buyers texted3 of 3 buyers texted
Its own tests15 of 15 pass21 of 21 pass

The plain agent did not put the provider in the sale. The brief said a sale must stay fast and the provider can be slow or down, and that was enough: it answers 201, then starts the text in the background with three retries and the sale as the key. Every sale was fast in every question, and a short outage cost nothing.

What it has no place for is the text it still owes. The promise lives in memory. Restart the server with three texts pending, or keep the provider down longer than three tries, and those buyers never hear from the venue, and nothing records that they should have.

server.ts · plain prompt
  // Respond immediately; the sale is already durable. The confirmation
  // text is sent in the background so a slow/down SMS provider can never
  // slow down a sale.
  sendJson(res, 201, { saleId, seat });

  void sendConfirmationSms(saleId, seat, phone).catch((err: unknown) => {
    console.error(`sms confirmation failed for sale ${saleId} (seat ${seat}):`, err);
  });

The events build keeps the texts consumer’s position in the database and moves it only after the provider answers. It sent every owed text after a restart and after a 40-second outage, and never sent one twice, including when the provider sent a text and dropped the connection before answering.

src/consumers/smsConsumer.ts · events prompt
      const offset = getOffset(db, CONSUMER_NAME);
      …
      const idempotencyKey = `sale-${payload.saleId}`;
      …
      // Only advance the offset once the provider has confirmed the send.
      setOffset(db, CONSUMER_NAME, row.id);

So the line the runs showed was missing from the plain brief is not “make the sale fast”, which the agent already understood. It is “every text a sale owes is recorded with the sale, and whatever sends it keeps its place across a restart”. That is a log and a position, whatever the prompt calls them.

How the runs were made and checkedTwo builds, recorded as written
  • Both agents started in empty folders and were launched at the same time; neither was told about the other, the lesson, or the checker.
  • Both builds are kept byte for byte with checksums. For every question the checker restores a build into a fresh folder with its own database, runs its own SMS provider, and stops the server with a kill signal where a question needs a restart.
  • The checker ran three times; the third added the long outage. Every answer the runs share is the same.
  • Both agents wrote a few scratch files outside their folders and removed all but one empty file, which was removed after the run. The events agent used one port in the checker’s range while no checker was running. Neither stopped a process by name or pattern.
  • One run of each prompt is a sample, not a measurement of the model.

09 / Hold it there

The next feature will want to call a reader from the sale. Three checks say no.

  1. The database’s door: one transaction for the sale and its event

    The rule “a sale is its event” holds only if both land in one commit. In a database with transactions, append the event in the same transaction as the sale, and give the log a primary key on its offset so nothing overwrites it. Where the event has to reach a separate broker, that is the gap Transactional outbox closes.

  2. A rule a check enforces: the box office imports no reader

    Nothing under the box office may import the seat map, the tally, the texts, or the SMS client. An import rule of the kind Enforcement layer runs catches the diff in section 05 before review does.

  3. A check on what happens: lag, and texts per sale

    Expose every consumer’s position beside the log’s end, and alert when lag passes its target. Count texts against sales daily: zero means a lost text, two means a consumer that is not safe to repeat.

Build UIs?Your components already announce facts; a live page is already a consumer.

Where it already is in your components

A component that calls onSeatSelected, or dispatches a DOM event, is saying what happened and letting the page decide who listens. The same seat picker works under a basket, a price summary, and analytics, because it never calls any of them.

When you have to own it

A live seat map fed from the server is a consumer of the log, and it can fall behind. With server-sent events, the browser keeps the position for you: when the connection drops and it reconnects, the HTML standard has it send the last event id it saw as the Last-Event-ID header (HTML, Server-sent events). Your server has to resume from that offset, and your page has to say it may be behind.

A seat picker that reports a selection and knows nothing about who reacts.

ReactAlready in your code
SeatPicker.tsx
// A seat picker says what happened, and nothing about who should care. The
// page decides who listens: the basket, the price summary, analytics.
type Seat = { id: string; section: 'A' | 'B' | 'C'; free: boolean };

export function SeatPicker({
	seats,
	onSeatSelected
}: {
	seats: Seat[];
	onSeatSelected: (event: { seat: string; section: Seat['section'] }) => void;
}) {
	return (
		<div role="group" aria-label="Seats">
			{seats.map((seat) => (
				<button
					key={seat.id}
					type="button"
					disabled={!seat.free}
					onClick={() => onSeatSelected({ seat: seat.id, section: seat.section })}
				>
					{seat.id}
				</button>
			))}
		</div>
	);
}

10 / Make the call

Call when you need the answer. Record an event when others only need to know.

If the sale cannot finish without an answer, such as whether the card was charged, call. If the others only need to know that it happened, record the fact and let them read it. Reach for the log when a slow or failing reader must not touch the sale, or when readers arrive later and need the history. Keep calling for one reader that is fast, rarely fails, and whose failure you can afford to lose.

Take it with you

Explain it without saying “event-driven”: “The box office writes each sale in a ledger and gets back to selling. The seat map, the report, and the text service each read the ledger at their own speed, and each keeps a bookmark.” Then find the handler in your own code that calls the most other things after its main write, and ask which of them it actually waits for an answer from.

Paste into your next prompt, and fill in the blanks

[The box office] decides [a sale] and records it as a fact, [SeatSold], in an append-only log, in the same transaction as the decision. It calls no other part of the system.
[The seat map], [the sales report], and [the confirmation texts] are consumers of the log. Each keeps its own position in the database, moves it only after finishing an event, and catches up on its own, so a slow or failing consumer never slows or fails [a sale], and nothing is lost across a restart.
Events describe what happened, with the data a reader needs; they are not instructions to one consumer.
Delivery is at least once: every consumer is safe to run twice on the same event, and a consumer that calls an outside service sends [the event's id] as the idempotency key.
A new consumer starts at the beginning of the log. Expose each consumer's lag, and alert when it passes [N seconds].
Connections to follow nextRelated lessons

Take the venue into your editor. Add a consumer that texts the next person on a waitlist whenever a sale is refunded, and decide which new event the box office has to record for it.

Back to architecture →