Exercise 15 · Write and publish are not one transaction (dual write)

Transactional Outbox

  • dual write
  • Outbox
  • relay
  • Inbox + Outbox

Principle

The service writes the event to the event store and then publishes it to the broker. These are two operations on two systems and cannot be done atomically. In between, the service may crash or the broker may fail.

Result: the event is stored, but nobody learns about it and the process stalls.

Transactional Outbox: the event and a “to send” record are written in one transaction. A separate relay then delivers them to the broker and retries until it succeeds. Together with an Inbox at the receiver this gives effectively “exactly once”.

What to watch

  • The “publish failed” effect and the “process stuck” state.
  • With Outbox: rows waiting for the relay and their attempts.
  • The “Rebuild projection” button shows that the event store had the truth.

Without the solution / With the solution

Without the solution

Run with 70% publish loss

Publishing to the broker fails (70 %). Events are written to the event store, but some of them are never delivered.

Run in the simulator without the solution

With the solution

Run with Outbox

The relay sends events from the outbox table until it succeeds. The process lags behind, but nothing is lost.

Run in the simulator with the solution

What to do: Click “Run with 70% publish loss”. What to look at: The event is stored, but the process never completes. The simulator needs JavaScript and runs as a separate application.

Try it in your own project

Copy the prompt into your coding agent (Claude Code, Copilot, Codex…) and run it in your project's repository.

Replace direct publishing after a write with a transactional Outbox and a relay process.

Language
Variant
# Task for a coding agent: Transactional Outbox

Stack: Java. Mode: guided.
Goal: Replace direct publishing after a write with a transactional Outbox and a relay process.

## Context of the existing code

You are working in an existing project (Java backend (typically Java 21, Spring Boot, JPA or JDBC). Use the build tool and style the project already has (Maven/Gradle, JUnit 5).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has a broker and producers that publish after a write. If not, first set up a minimal producer: a state write and `publish` to an in-memory broker.

Read first, change nothing. Find and briefly summarise:
- Every place where `publish`/`send` is called after `save` (dual write), consumers included, and for each one where the state is written (same database, another service, none).
- Whether the project's broker library already offers an Outbox (an event store with a relay from the feed already is an outbox).
- The transaction boundary (one DB transaction).
- Which of these parts already exist in the project and which are missing. Before writing your own infrastructure (outbox, relay), check whether the project or organisation already has it as a ready-made library, and use it. If it is missing, propose it in the plan.

## Expected behaviour

Writing state and publishing are two operations on two systems: if the service or broker fails between them, the state is stored but nobody learns about it and the process stalls. The Outbox writes a “to send” row in the same transaction as the state and the relay delivers it until it succeeds. It delivers “at least once”; with an Inbox at the receiver you get effectively “exactly once”. Example: the broker rejects 70 % of publishes; the process lags behind, but nothing is lost.

## Specific task

Technical specifics (Java): `@Transactional` write, relay as `@Scheduled` or Debezium CDC, if the organisation has it.

Proceed step by step. After point 1 (plan), write what you found and what you propose, and wait for confirmation:
1. Plan: the outbox table schema (id, type, topic, payload, headers, aggregate_key, position, created_at, sent_at, attempts, next_attempt_at, locked_until) and where it lives when the state is in another service.
2. Write the state and the outbox row in one transaction; a producer without its own write writes only the outbox row.
3. Relay (background worker) after a write and on a timer: loads unsent rows, sends them in order, marks them; failure = `attempts + 1` and the next attempt after backoff.
4. Subscribers are idempotent (Inbox, unique business key, idempotency key), the relay delivers “at least once”; where this does not hold, state it in the conclusion.
5. Test through a faulty broker: (a) broker down → after recovery all messages in order; (b) crash between write and publish; (c) lost acknowledgement → duplicate, effect once. Control: without the outbox, (b) fails.

Watch out for:
- Multiple relay instances: `SKIP LOCKED` only on the aggregate's first unsent row and a lock with a lease, otherwise the second instance overtakes the first; or a single instance.
- A message the broker permanently rejects blocks its aggregate: alarm on outbox age.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- Broker outage tests (a) to (c) and the control without the outbox.
- Writing the state and the outbox is atomic (test with an error inside the transaction).
- Relay in multiple instances without double publishing and with per-aggregate order (test), or a documented single instance.
- JUnit 5 tests in the existing `src/test/java` structure pass with the project's build tool.
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Transactional Outbox

Stack: Java. Mode: challenge.
Goal: Replace direct publishing after a write with a transactional Outbox and a relay process.

## Context of the existing code

You are working in an existing project (Java backend (typically Java 21, Spring Boot, JPA or JDBC). Use the build tool and style the project already has (Maven/Gradle, JUnit 5).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has a broker and producers that publish after a write. If not, first set up a minimal producer: a state write and `publish` to an in-memory broker.

Read first, change nothing. Find and briefly summarise:
- Every place where `publish`/`send` is called after `save` (dual write), consumers included, and for each one where the state is written (same database, another service, none).
- Whether the project's broker library already offers an Outbox (an event store with a relay from the feed already is an outbox).
- The transaction boundary (one DB transaction).
- Which of these parts already exist in the project and which are missing. Before writing your own infrastructure (outbox, relay), check whether the project or organisation already has it as a ready-made library, and use it. If it is missing, propose it in the plan.

## Expected behaviour

Writing state and publishing are two operations on two systems: if the service or broker fails between them, the state is stored but nobody learns about it and the process stalls. The Outbox writes a “to send” row in the same transaction as the state and the relay delivers it until it succeeds. It delivers “at least once”; with an Inbox at the receiver you get effectively “exactly once”. Example: the broker rejects 70 % of publishes; the process lags behind, but nothing is lost.

## Brief (Challenge)

Guarantee that every stored event is sooner or later published, even if the broker or the service fails in the meantime.

Technical specifics (Java): `@Transactional` write, relay as `@Scheduled` or Debezium CDC, if the organisation has it.

Choose your own approach. The pitfalls in this topic are not listed: find them (concurrency, redelivery, ordering, failure midway) and for each write a test that would expose it. At the end, list which pitfalls you found and which you deliberately left open.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- Broker outage tests (a) to (c) and the control without the outbox.
- Writing the state and the outbox is atomic (test with an error inside the transaction).
- Relay in multiple instances without double publishing and with per-aggregate order (test), or a documented single instance.
- JUnit 5 tests in the existing `src/test/java` structure pass with the project's build tool.
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Transactional Outbox

Stack: C#. Mode: guided.
Goal: Replace direct publishing after a write with a transactional Outbox and a relay process.

## Context of the existing code

You are working in an existing project (C# backend (.NET, ASP.NET Core, EF Core or Dapper). Use the solution, style and DI conventions the project already has (xUnit/NUnit).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has a broker and producers that publish after a write. If not, first set up a minimal producer: a state write and `publish` to an in-memory broker.

Read first, change nothing. Find and briefly summarise:
- Every place where `publish`/`send` is called after `save` (dual write), consumers included, and for each one where the state is written (same database, another service, none).
- Whether the project's broker library already offers an Outbox (an event store with a relay from the feed already is an outbox).
- The transaction boundary (one DB transaction).
- Which of these parts already exist in the project and which are missing. Before writing your own infrastructure (outbox, relay), check whether the project or organisation already has it as a ready-made library, and use it. If it is missing, propose it in the plan.

## Expected behaviour

Writing state and publishing are two operations on two systems: if the service or broker fails between them, the state is stored but nobody learns about it and the process stalls. The Outbox writes a “to send” row in the same transaction as the state and the relay delivers it until it succeeds. It delivers “at least once”; with an Inbox at the receiver you get effectively “exactly once”. Example: the broker rejects 70 % of publishes; the process lags behind, but nothing is lost.

## Specific task

Technical specifics (C#): `DbContext` transaction, relay as a `BackgroundService`; first check the outbox in the project's messaging library.

Proceed step by step. After point 1 (plan), write what you found and what you propose, and wait for confirmation:
1. Plan: the outbox table schema (id, type, topic, payload, headers, aggregate_key, position, created_at, sent_at, attempts, next_attempt_at, locked_until) and where it lives when the state is in another service.
2. Write the state and the outbox row in one transaction; a producer without its own write writes only the outbox row.
3. Relay (background worker) after a write and on a timer: loads unsent rows, sends them in order, marks them; failure = `attempts + 1` and the next attempt after backoff.
4. Subscribers are idempotent (Inbox, unique business key, idempotency key), the relay delivers “at least once”; where this does not hold, state it in the conclusion.
5. Test through a faulty broker: (a) broker down → after recovery all messages in order; (b) crash between write and publish; (c) lost acknowledgement → duplicate, effect once. Control: without the outbox, (b) fails.

Watch out for:
- Multiple relay instances: `SKIP LOCKED` only on the aggregate's first unsent row and a lock with a lease, otherwise the second instance overtakes the first; or a single instance.
- A message the broker permanently rejects blocks its aggregate: alarm on outbox age.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- Broker outage tests (a) to (c) and the control without the outbox.
- Writing the state and the outbox is atomic (test with an error inside the transaction).
- Relay in multiple instances without double publishing and with per-aggregate order (test), or a documented single instance.
- Tests in the existing test project pass (`dotnet test`).
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Transactional Outbox

Stack: C#. Mode: challenge.
Goal: Replace direct publishing after a write with a transactional Outbox and a relay process.

## Context of the existing code

You are working in an existing project (C# backend (.NET, ASP.NET Core, EF Core or Dapper). Use the solution, style and DI conventions the project already has (xUnit/NUnit).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has a broker and producers that publish after a write. If not, first set up a minimal producer: a state write and `publish` to an in-memory broker.

Read first, change nothing. Find and briefly summarise:
- Every place where `publish`/`send` is called after `save` (dual write), consumers included, and for each one where the state is written (same database, another service, none).
- Whether the project's broker library already offers an Outbox (an event store with a relay from the feed already is an outbox).
- The transaction boundary (one DB transaction).
- Which of these parts already exist in the project and which are missing. Before writing your own infrastructure (outbox, relay), check whether the project or organisation already has it as a ready-made library, and use it. If it is missing, propose it in the plan.

## Expected behaviour

Writing state and publishing are two operations on two systems: if the service or broker fails between them, the state is stored but nobody learns about it and the process stalls. The Outbox writes a “to send” row in the same transaction as the state and the relay delivers it until it succeeds. It delivers “at least once”; with an Inbox at the receiver you get effectively “exactly once”. Example: the broker rejects 70 % of publishes; the process lags behind, but nothing is lost.

## Brief (Challenge)

Guarantee that every stored event is sooner or later published, even if the broker or the service fails in the meantime.

Technical specifics (C#): `DbContext` transaction, relay as a `BackgroundService`; first check the outbox in the project's messaging library.

Choose your own approach. The pitfalls in this topic are not listed: find them (concurrency, redelivery, ordering, failure midway) and for each write a test that would expose it. At the end, list which pitfalls you found and which you deliberately left open.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- Broker outage tests (a) to (c) and the control without the outbox.
- Writing the state and the outbox is atomic (test with an error inside the transaction).
- Relay in multiple instances without double publishing and with per-aggregate order (test), or a documented single instance.
- Tests in the existing test project pass (`dotnet test`).
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Transactional Outbox

Stack: Web (HTML/JS or the app's stack). Mode: guided.
Goal: Replace direct publishing after a write with a transactional Outbox and a relay process.

## Context of the existing code

You are working in an existing project (web application: a browser frontend, possibly with its own backend. If the project has a backend, the event store, broker and services belong on the backend, and the points about endpoints and tables refer to it. If it has no backend, use IndexedDB in the browser and `fake-indexeddb` in tests. Keep the project's framework and test runner; add new dependencies only with a justification.).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has a broker and producers that publish after a write. If not, first set up a minimal producer: a state write and `publish` to an in-memory broker.

Read first, change nothing. Find and briefly summarise:
- Every place where `publish`/`send` is called after `save` (dual write), consumers included, and for each one where the state is written (same database, another service, none).
- Whether the project's broker library already offers an Outbox (an event store with a relay from the feed already is an outbox).
- The transaction boundary (one DB transaction).
- Which of these parts already exist in the project and which are missing. Before writing your own infrastructure (outbox, relay), check whether the project or organisation already has it as a ready-made library, and use it. If it is missing, propose it in the plan.

## Expected behaviour

Writing state and publishing are two operations on two systems: if the service or broker fails between them, the state is stored but nobody learns about it and the process stalls. The Outbox writes a “to send” row in the same transaction as the state and the relay delivers it until it succeeds. It delivers “at least once”; with an Inbox at the receiver you get effectively “exactly once”. Example: the broker rejects 70 % of publishes; the process lags behind, but nothing is lost.

## Specific task

Technical specifics (Web (HTML/JS or the app's stack)): Outbox on the backend; in the frontend, optionally store in an IndexedDB “outbox” only safely repeatable commands (same `X-Correlation-Id`) and send them on `online`.

Proceed step by step. After point 1 (plan), write what you found and what you propose, and wait for confirmation:
1. Plan: the outbox table schema (id, type, topic, payload, headers, aggregate_key, position, created_at, sent_at, attempts, next_attempt_at, locked_until) and where it lives when the state is in another service.
2. Write the state and the outbox row in one transaction; a producer without its own write writes only the outbox row.
3. Relay (background worker) after a write and on a timer: loads unsent rows, sends them in order, marks them; failure = `attempts + 1` and the next attempt after backoff.
4. Subscribers are idempotent (Inbox, unique business key, idempotency key), the relay delivers “at least once”; where this does not hold, state it in the conclusion.
5. Test through a faulty broker: (a) broker down → after recovery all messages in order; (b) crash between write and publish; (c) lost acknowledgement → duplicate, effect once. Control: without the outbox, (b) fails.

Watch out for:
- Multiple relay instances: `SKIP LOCKED` only on the aggregate's first unsent row and a lock with a lease, otherwise the second instance overtakes the first; or a single instance.
- A message the broker permanently rejects blocks its aggregate: alarm on outbox age.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- Broker outage tests (a) to (c) and the control without the outbox.
- Writing the state and the outbox is atomic (test with an error inside the transaction).
- Relay in multiple instances without double publishing and with per-aggregate order (test), or a documented single instance.
- Tests pass with the existing test runner; lint and build pass if the project has them.
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Transactional Outbox

Stack: Web (HTML/JS or the app's stack). Mode: challenge.
Goal: Replace direct publishing after a write with a transactional Outbox and a relay process.

## Context of the existing code

You are working in an existing project (web application: a browser frontend, possibly with its own backend. If the project has a backend, the event store, broker and services belong on the backend, and the points about endpoints and tables refer to it. If it has no backend, use IndexedDB in the browser and `fake-indexeddb` in tests. Keep the project's framework and test runner; add new dependencies only with a justification.).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has a broker and producers that publish after a write. If not, first set up a minimal producer: a state write and `publish` to an in-memory broker.

Read first, change nothing. Find and briefly summarise:
- Every place where `publish`/`send` is called after `save` (dual write), consumers included, and for each one where the state is written (same database, another service, none).
- Whether the project's broker library already offers an Outbox (an event store with a relay from the feed already is an outbox).
- The transaction boundary (one DB transaction).
- Which of these parts already exist in the project and which are missing. Before writing your own infrastructure (outbox, relay), check whether the project or organisation already has it as a ready-made library, and use it. If it is missing, propose it in the plan.

## Expected behaviour

Writing state and publishing are two operations on two systems: if the service or broker fails between them, the state is stored but nobody learns about it and the process stalls. The Outbox writes a “to send” row in the same transaction as the state and the relay delivers it until it succeeds. It delivers “at least once”; with an Inbox at the receiver you get effectively “exactly once”. Example: the broker rejects 70 % of publishes; the process lags behind, but nothing is lost.

## Brief (Challenge)

Guarantee that every stored event is sooner or later published, even if the broker or the service fails in the meantime.

Technical specifics (Web (HTML/JS or the app's stack)): Outbox on the backend; in the frontend, optionally store in an IndexedDB “outbox” only safely repeatable commands (same `X-Correlation-Id`) and send them on `online`.

Choose your own approach. The pitfalls in this topic are not listed: find them (concurrency, redelivery, ordering, failure midway) and for each write a test that would expose it. At the end, list which pitfalls you found and which you deliberately left open.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- Broker outage tests (a) to (c) and the control without the outbox.
- Writing the state and the outbox is atomic (test with an error inside the transaction).
- Relay in multiple instances without double publishing and with per-aggregate order (test), or a documented single instance.
- Tests pass with the existing test runner; lint and build pass if the project has them.
- In the summary, state: what you changed, what you assumed, what remains.

Training for your team

Would you like this training for your team?

We run the training on your code and your domain problem. We agree the scope, dates and format with you.

Discuss the training