Skip to content

docs(examples): add outbox, the transactional-outbox recipe - #66

Merged
laconc merged 1 commit into
feat/journal-retentionfrom
docs/outbox-example
Aug 29, 2026
Merged

docs(examples): add outbox, the transactional-outbox recipe#66
laconc merged 1 commit into
feat/journal-retentionfrom
docs/outbox-example

Conversation

@laconc

@laconc laconc commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Stacked on #62 (it uses VersionedEvent::new, which that PR adds).

The seam a journal exposes for the caller's transaction — Journal::Tx, execute_in, Pending — is the reason the trait was reshaped, and it had no runnable example. An adopter could only learn it from ironstate-journal/tests/transactional.rs, which isn't indexed as an example and isn't where anyone looks. Every other tier has one: ledger, async-store, catalog-ctx.

The domain does the arguing

An order ships. Three things must become true at once:

  • the Shipped event is in the log,
  • the order's read-model row says shipped,
  • a "send the shipment confirmation" job is on the queue.

If the append commits and the job doesn't, the customer is never told their order shipped, and nothing in the system knows. That isn't a failure you can retry your way out of afterwards — the information that it needed doing is gone. Which is why it's one transaction and not a queue publish.

Stating it in a domain rather than abstractly matters here: "atomicity" as a word convinces nobody to restructure their write path.

What it shows

let mut tx = store.begin();
let pending = execute_in(&mut journal, &mut tx, &stream, &order, &cmd, &mut ctx)?;
tx.upsert_read_model(id, OrderRow::shipped());   // the caller's own writes,
tx.enqueue(Job::ShipmentConfirmation { id });    // in the same transaction
store.commit(tx);
let seq = pending.commit(&mut order);

Store is a few dozen lines of in-memory tables standing in for SQLite or Postgres. The point isn't the storage — it's that Transaction is the caller's type, the journal writes into it rather than around it, and nothing is visible until commit. Dropping it is the rollback, and needs no code.

Four tests, not one

  • a rolled-back transaction leaves no event, no projection, no queued job and an un-evolved aggregate — that last one being the half a journal-only check would miss;
  • a commit lands all four;
  • a retry after a rollback behaves like a first attempt — no sequence consumed, the job queued exactly once;
  • staged writes are invisible to the journal's own reads until commit, which is why execute_in is one call per transaction.

The README covers the two traps

Why execute_in deliberately does not evolve the aggregate (a rollback would otherwise leave it silently ahead of the durable log — the failure the seam exists to prevent, reintroduced one layer up), and the one-call-per-transaction constraint, since head/entropy_pos take no tx and see committed state only.

Indexing

All three places AGENTS.md requires: the examples table, the root README, and docs/testing.md. It stays out of the guide, like the other adoption recipes — the guide's arc is core → aggregate → journal, and this is a recipe for an integration rather than a step in learning the family.

Verification

cargo run -p outbox prints the store before, after a rollback, and after a commit. make check, make msrv, make doc, make wasm, make deny all pass.

🤖 Generated with Claude Code

@laconc
laconc force-pushed the docs/outbox-example branch 2 times, most recently from 9f7296d to f69d58c Compare August 29, 2026 16:00
The seam a journal exposes for the caller's transaction — `Journal::Tx`,
`execute_in`, `Pending` — is the reason the trait was reshaped, and it had no
runnable example. An adopter could only learn it from
`ironstate-journal/tests/transactional.rs`, which is not indexed as an example
and is not where anyone looks. Every other tier has one.

An order ships, and three things must become true together: the `Shipped`
event is in the log, the read model says `shipped`, and a confirmation job is
on the queue. If the append commits and the job does not, the customer is
never told and nothing knows — which is not a failure you can retry your way
out of afterwards, because the fact that it needed doing is gone. That is the
motivation for one transaction rather than a queue publish, stated in the
domain rather than in the abstract.

`Store` is a few dozen lines of in-memory tables standing in for SQLite or
Postgres; `Transaction` is the caller's type, and the journal writes into it
rather than around it. Four tests pin the behaviour: a rolled-back transaction
leaves no event, no projection, no queued job **and** an un-evolved aggregate;
a commit lands all four; a retry after a rollback behaves like a first attempt
(no sequence consumed, the job queued exactly once); and staged writes are
invisible to the journal's own reads until commit — which is why `execute_in`
is one call per transaction.

The README covers the two things an adopter gets wrong: why `execute_in` does
not evolve the aggregate, and the one-call-per-transaction constraint.

Indexed in all three places AGENTS.md requires — the examples table, the root
README, and docs/testing.md. It stays out of the guide, like the other
adoption recipes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@laconc
laconc force-pushed the docs/outbox-example branch from f69d58c to 34b0b5f Compare August 29, 2026 16:05
@laconc
laconc merged commit 418abd5 into main Aug 29, 2026
9 checks passed
@laconc
laconc deleted the docs/outbox-example branch August 29, 2026 17:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant