Skip to main content
ArticlesProjects

The Second Pattern: Transactional Outbox

Commit then notify loses messages. Notify then commit sends lies. The dual write problem has no clever solution, and Tempest ships more of the machinery than it advertises.

A booking gets made. Two things have to happen: the row goes in your database, and the customer’s system gets told. Here is the version everyone writes.

$database->withinTransaction(function () use ($booking) {
query(Booking::class)
->insert(
consignment_id: $booking->consignmentId,
carrier: $booking->carrier->value,
reference: $booking->reference,
)
->execute();
event(new ConsignmentBooked($booking));
});

That reads well, and wrapping it in a transaction feels like the responsible thing to do, which is most of why the bug survives review. It will not show up in your tests either, because the reason it is broken is one level down in GenericEventBus:

public function dispatch(string|object $event): void
{
$eventHandlers = $this->resolveHandlers($event);
$dispatch = $this->getCallable($eventHandlers);
$dispatch($event);
}

No queue. No deferral. It finds the handlers and runs them, right now, on this call stack. So whatever your ConsignmentBooked listener does, it does inside your open transaction.

If that listener posts a webhook to the customer, you have just told an external system about a booking that has not been committed yet. Should the insert fail afterwards, or should anything else in that closure throw, the database rolls back and the customer keeps the webhook. There is no rollback for an HTTP request somebody else already received.

So you end up with a booking that exists for your customer and does not exist for you, which is a difficult conversation to have with support, and an even more difficult one to have with the customer who is now expecting a parcel that no system of yours has any record of.

The obvious move

Move the dispatch out of the transaction. Commit first, then tell people.

$database->withinTransaction(function () use ($booking) {
query(Booking::class)->insert(/* … */)->execute();
});
event(new ConsignmentBooked($booking));

Better, and it accidentally exposes a second problem, because it is worth knowing what withinTransaction actually does with a failure:

public function withinTransaction(callable $callback): bool
{
$this->transactionManager->begin();
try {
$callback();
$this->transactionManager->commit();
} catch (Throwable) {
$this->transactionManager->rollback();
return false;
}
return true;
}

It catches Throwable, rolls back and returns false rather than rethrowing, so if you ignore the return value, as the snippet above quietly does, a failed transaction is indistinguishable from a successful one at the call site and you carry on and dispatch the event regardless. Which means checking it:

$committed = $database->withinTransaction(fn () => query(Booking::class)
->insert(/* … */)
->execute());
if (! $committed) {
throw new BookingFailed();
}
event(new ConsignmentBooked($booking));

That is correct now. It is also still broken, and this is the bit that catches people out, because the remaining hole is not a bug you can see in the code.

Between the commit succeeding and the event dispatching, there is a gap. Small, but real. The process can be killed in that gap. The container can be recycled, the deploy can land, the machine can lose power. The commit is durable, the dispatch was never attempted, and nothing anywhere records that the customer still needs telling.

You cannot close that gap by reordering. Commit then notify loses notifications. Notify then commit sends lies. Two systems, one of which is a database and one of which is not, and no way to make a single atomic decision across both. This is the dual write problem, and it has been eating people’s lunch for as long as there have been two systems.

The pattern

The trick is to stop trying to write to two systems.

Write only to the database, but write twice: the business row and a record of the message you owe. Both in the same transaction, so they commit or vanish together. A separate process reads the messages table and delivers them, marking each one done. Delivery becomes something that happens later, driven by durable state, rather than something that has to happen right now on this call stack.

That is a Transactional Outbox. Chris Richardson catalogued it under that name in his microservices patterns work, though the underlying idea is older and shows up wherever reliable messaging does.

The trade you are making is worth stating plainly. You give up exactly-once and you accept at-least-once. If the relay crashes after delivering a message but before marking it done, it delivers again on restart. That consequence lands squarely on your consumers, which is why the next article in this series is about idempotent receivers. The two are a pair, and shipping one without the other is how people end up sending duplicate booking confirmations.

When this is worth it

The test I use: if a consumer never hears about something that definitely happened, is that an incident?

For a booking confirmation to a paying customer, obviously yes. For a metrics counter, obviously no, and adding an outbox to your analytics pipeline is a waste of everyone’s afternoon.

It also becomes worth it the moment the consumer is outside your transaction boundary. Anything reached over HTTP, anything on a broker, anything owned by another team. Those are the writes you cannot roll back.

There is also the question that arrives weeks later, usually from someone in support, of whether you ever actually sent the thing. A message you owe is a row someone can look at, which beats a shrug and a grep through whatever your log retention happens to be. Do check what your implementation keeps, mind. Tempest’s deletes the row on success, so what you get is a queue of outstanding work rather than a history, and if you want the history you have to keep it yourself.

When it is not

If your consumer is a table in the same database, you do not need any of this. Write the row, done, one transaction, no relay, no pending state. People do reach for an outbox here, and it is pure ceremony.

Nor do you need it when losing the message is genuinely fine, which covers cache invalidation that a TTL will fix anyway, or a counter on a dashboard nobody makes decisions from. The way to find out is to say out loud in a standup that this message will occasionally go missing, and watch whether anybody flinches.

The case that needs more care is strict ordering. An outbox on its own does not give you ordered delivery, and Tempest’s implementation certainly does not, as we are about to see. If your consumer depends on receiving movements in the order they occurred, an outbox is necessary but nowhere near sufficient, and you should plan for sequence numbers and consumer-side buffering on top.

Building it in Tempest

I did not expect what I found when I went digging. Tempest already ships the machinery for this. It is just not wired up by default, and the default it ships with is the dangerous one.

Start there, because everything else depends on it. CommandBusConfig picks the repository, and out of the box it picks this one:

public string $commandRepositoryClass = FileCommandRepository::class;

Which stores a pending command as a file:

public function store(string $uuid, object $command): void
{
$payload = serialize($command);
Filesystem\write_file(__DIR__ . "/../stored-commands/{$uuid}.pending.txt", $payload);
}

A file write is not part of your database transaction and does not roll back with it. So on an untouched installation, moving the dispatch inside the transaction buys you nothing at all. The transaction rolls back, the file survives, the relay picks it up, and the customer is told about a booking that does not exist. That is the same bug we started with wearing a costume, and it is harder to spot, because the code now looks like somebody thought about it.

The repository you want is the other one, and the framework has an installer that publishes it for you. What that writes is a three line config file:

use Tempest\CommandBus\AsyncCommandRepositories\DatabaseCommandRepository;
use Tempest\CommandBus\CommandBusConfig;
return new CommandBusConfig(
commandRepositoryClass: DatabaseCommandRepository::class,
);

That is the whole of the difference, and it is worth going and looking rather than assuming, because the installer offers the database option as its default while the framework’s compiled-in default is the file. Which one you have depends on whether anybody ever ran it.

With that config in place, store() becomes an ordinary insert on your ordinary connection:

public function store(string $uuid, object $command): void
{
query(StoredCommand::class)
->insert(
id: $uuid,
payload: serialize($command),
)
->execute();
}

Which means when you dispatch an async command inside a transaction, the insert is inside that transaction, and it rolls back with everything else.

Now the command table is an outbox table and command:monitor is the relay. Nobody appears to advertise it in those terms, but structurally that is what it is.

So the fix to our booking is smaller than the pattern’s reputation suggests:

use Tempest\CommandBus\Async;
#[Async]
final readonly class NotifyCustomerOfBooking
{
public function __construct(
public string $consignmentId,
public string $reference,
) {}
}
$committed = $database->withinTransaction(function () use ($booking) {
query(Booking::class)->insert(/* … */)->execute();
command(new NotifyCustomerOfBooking(
consignmentId: $booking->consignmentId,
reference: $booking->reference,
));
});
if (! $committed) {
throw new BookingFailed();
}

Notice the dispatch moved back inside the transaction, which is the opposite of what we did earlier and is now the correct place for it. AsyncCommandMiddleware intercepts anything carrying #[Async], stores it, and returns without calling the handler. Nothing leaves the process. The row and the intent commit together or not at all, and the gap is gone.

The other half, which is where the work is

The command is only the intent. Something still has to make the HTTP call, and that lives in a handler:

use Tempest\CommandBus\CommandHandler;
final readonly class NotifyCustomerOfBookingHandler
{
public function __construct(
private CustomerWebhookClient $webhooks,
) {}
#[CommandHandler]
public function handle(NotifyCustomerOfBooking $command): void
{
$this->webhooks->send(
consignmentId: $command->consignmentId,
reference: $command->reference,
);
}
}

Discovered by the attribute, resolved from the container, invoked by the monitor in a separate process, all of which is about as unremarkable as it should be. The interesting question is what happens when send() throws, and the answer is the single most important thing to know before you lean on any of this.

} catch (Throwable $throwable) {
$this->repository->markAsFailed($uuid);
$this->error($throwable->getMessage());
return ExitCode::ERROR;
}

markAsFailed() writes a timestamp into failed_at. And getPendingCommands() selects whereNull('failed_at').

Put those two together and a command whose handler throws gets marked failed and is then excluded from every future pass, so it is never retried, with or without backoff. What you have is a dead letter queue, which is a perfectly good thing to have and not at all the thing you were expecting. The customer’s endpoint returning a 503 for four seconds is the most ordinary failure in this entire system, and under this arrangement that booking confirmation is gone. Nobody is paged, because from the application’s point of view nothing went wrong. There is just a row with a timestamp in it that no code will ever read again.

Be fair to the design, though: it is a sane default for a general command bus, where blindly retrying an arbitrary command can do real damage. It is only dangerous when you use the mechanism as an outbox, because guaranteed delivery is the entire point of an outbox and this does not guarantee delivery.

You have two honest options. Handle retries inside the handler, so the throw only escapes once you have genuinely given up, which is the smaller change and keeps the framework’s semantics intact. Or swap the repository for one that treats failed_at as a retry marker with an attempt count and a backoff, which is more work and gives you something you can actually reason about.

Either way it wants deciding on purpose, because the failure mode of not deciding is that nothing tells you anything.

What else you are not getting

The retry gap is the big one, but it is not the only edge.

Look at what happens to a payload that cannot be read back:

try {
$command = unserialize($row->payload);
} catch (Throwable) {
continue;
}
if (! is_object($command)) {
continue;
}

Two guards, and the second one does most of the work, because unserialize() does not throw on garbage. It raises a warning and returns false, so the catch is close to decorative and the is_object() check is what actually catches a corrupt payload. Either way the row is skipped. Not logged, not marked failed, not surfaced. It stays in the table, pending forever, and every subsequent pass skips it again in silence.

The more interesting case gets past both guards. The payload is serialize() of your command object, so rename that class or move it between namespaces and unserialize() hands back a __PHP_Incomplete_Class. That is an object. It satisfies is_object(), lands in the pending list, and gets a process spawned for it, at which point command:handle finds no handler for the class and returns ExitCode::ERROR. Returns, note, rather than throws, so the catch block that would have marked it failed never runs. The row stays pending. The monitor picks it up again on the next pass, and the pass after that, and spawns a process for it every time.

So a rename during a deploy with a backlog does not quietly lose you messages. It leaves you a row that can never be delivered and a relay that will keep trying to deliver it for as long as the process is up. The outbox still looks healthy from the outside: no failures, no errors, just work that never finishes.

There is no ordering, either. getPendingCommands() selects everything with a null failed_at and no ORDER BY, so delivery order is whatever the database feels like returning.

And there is no atomic claim on a pending command. The monitor reads the pending list, takes a uuid, spawns a process for it. Two monitors would both take the same one, so you get exactly one relay process, which is also your throughput ceiling. Concurrency inside the monitor is fixed at five child processes.

None of this makes the mechanism unusable. It makes it a young implementation with known edges, and knowing them is the difference between using it deliberately and finding out in production.

If you need better, commandBusConfig->commandRepositoryClass is swappable, so a repository that stores a versioned payload rather than a PHP-serialised object, claims rows atomically and orders its output is entirely within reach. That is a genuinely good afternoon’s work and I would rather have it than a second broker in my infrastructure.

The test worth writing

The property this whole article is about is one line to assert: when the transaction rolls back, no message survives.

it('does not owe a message for a booking that never happened', function () {
$committed = database()->withinTransaction(function () {
query(Booking::class)->insert(/* … */)->execute();
command(new NotifyCustomerOfBooking(
consignmentId: '01JQ…',
reference: 'CB-99182',
));
throw new CarrierRejectedBooking();
});
expect($committed)->toBeFalse();
expect(query(Booking::class)->select()->all())->toBeEmpty();
expect(query(StoredCommand::class)->select()->all())->toBeEmpty();
});

The third assertion is the one carrying the weight. If somebody later moves that command() call outside the transaction because it reads more naturally there, this fails straight away and tells them why. Without it, the regression is invisible until a customer is notified about a booking that was rolled back, which is a considerably more expensive way to find out.

Worth running the inverse too: commit successfully, assert the stored command is there and the handler has not run. That is the assertion that catches somebody quietly removing #[Async] and turning your outbox back into an inline call.

What it costs

You need a process running that nobody thinks about. This is the honest cost, and it is the one that gets underestimated most, because the pattern looks like it lives in your application code. It does not. Half of it lives in your deployment.

Everything above is worthless if command:monitor is not running. Your bookings will commit beautifully and your customers will hear nothing, and because the application is behaving perfectly, nothing will page you. The alert you need is not on errors, it is on the age of the oldest pending row. If something is sitting in that table for more than a few minutes, the relay is down, and that is the only signal that will tell you.

Then there is latency, because you have converted an inline call into a poll and notification now takes however long the loop takes to come round. For a booking confirmation arriving by email that is invisible, but if there is a human sitting in front of a screen waiting for something to appear, they will notice, and they will report it as a bug.

And you have accepted duplicates, which was the deal you made in the first place, so every consumer downstream now has to cope with seeing the same message twice. That is not free for them, and it is not something they should have to work out for themselves when it happens, so tell them before you ship it.

Where this leaves us

The dual write problem has no clever solution. You cannot make two systems commit atomically by being careful about ordering, and every arrangement of commit and notify trades one failure for another. The outbox works by refusing to play: write once, to one system, and let a relay deal with the rest.

If you are on Tempest and using async commands with the database repository, you have most of the machinery already, whether or not you knew it. What you do not have is the guarantees you probably assumed came with it, and the gap between those two things is the whole reason to read the source.

Next in the series: the relay crashes, restarts, and sends the booking confirmation a second time. Idempotent receivers, and the unusual position of being handed the pattern by the framework.

Part of a Series

The Second Pattern

You are reading Part 3 of 10 in this learning series.

View Full Series

Share

XLinkedIn

Related

Keep Reading

All posts →