Skip to main content
ArticlesProjects

The Second Pattern: Process Manager

Everyone calls it a Saga. Most of the time it is a Process Manager, and the muddle is why teams adopt the word and then never write the compensations it never promised them.

Booking a consignment with Carrier C is not one operation. You quote it, you reserve capacity, you submit the booking, you wait for their webhook to tell you whether it was accepted, you generate a label, and if it is going outside the UK you file customs paperwork before any of it can move.

Six steps, three of which call somebody else’s system, one of which cannot complete in the same request it started in. And the way this gets built, almost every time, is a column.

final class Consignment
{
use IsDatabaseModel;
public ConsignmentStatus $status;
}
enum ConsignmentStatus: string
{
case DRAFT = 'draft';
case QUOTED = 'quoted';
case CAPACITY_RESERVED = 'capacity_reserved';
case BOOKING_SUBMITTED = 'booking_submitted';
case BOOKING_ACCEPTED = 'booking_accepted';
case BOOKING_REJECTED = 'booking_rejected';
case AWAITING_CUSTOMS = 'awaiting_customs';
case CUSTOMS_CLEARED = 'customs_cleared';
case LABEL_GENERATED = 'label_generated';
case READY_FOR_COLLECTION = 'ready_for_collection';
case FAILED = 'failed';
}

Eleven values, and somewhere in the codebase a method that decides what to do with each of them.

public function advance(Consignment $consignment): void
{
if ($consignment->status === ConsignmentStatus::QUOTED) {
$this->reserveCapacity($consignment);
return;
}
if ($consignment->status === ConsignmentStatus::CAPACITY_RESERVED) {
$this->submitBooking($consignment);
return;
}
if ($consignment->status === ConsignmentStatus::BOOKING_ACCEPTED) {
if ($consignment->destinationCountry !== 'GB') {
$this->fileCustoms($consignment);
return;
}
$this->generateLabel($consignment);
return;
}
// and so on
}

This works, and it has worked in production for years in plenty of companies, including some very large ones, so I am not going to suggest anybody rewrite it on a Tuesday afternoon on my say-so.

What it cannot do is answer questions about itself.

Nobody can tell you, from that code, which consignments have been sitting in BOOKING_SUBMITTED for six hours because Carrier C never sent their webhook. The status says where a consignment got to, not when it got there or what it was waiting for. There is no record of what the quote said by the time you reach customs, so if anything downstream needs it you go and fetch it again. And when the label generation fails after the booking succeeded, nothing in that enum describes the situation you are actually in, which is that a carrier believes it is collecting a parcel you cannot produce paperwork for.

The obvious move

Make it a proper state machine. Define which transitions are legal, refuse the rest, and stop the class of bug where something jumps from DRAFT to LABEL_GENERATED because of an ordering mistake in a queue.

public function transitionTo(ConsignmentStatus $next): void
{
if (! in_array($next, $this->status->allowedTransitions(), strict: true)) {
throw new IllegalTransition($this->status, $next);
}
$this->status = $next;
$this->save();
}

I like this a lot more than the if-chain and it fixes real bugs. It is also answering a different question from the one that was being asked.

A state machine tells you which transitions are permitted. It has no opinion about which one should happen next, so the if-chain does not go away, it just gets a guard rail. There is still nowhere to keep what you learned in step two by the time you need it in step five, so that data lives on the consignment itself and the consignment slowly accretes fields that only matter during booking.

And it has nothing to say about failure. A state machine can move you to FAILED. It cannot tell you that you now owe Carrier C a cancellation, because you successfully reserved capacity with them ninety seconds ago and the thing that just failed was the customs filing.

The pattern, and the one it gets confused with

Everyone calls this a Saga. Most of the time what they have built is a Process Manager, and while I would normally let a naming quibble go, this one causes people to get compensation wrong.

Saga comes from a 1987 paper by Hector Garcia-Molina and Kenneth Salem. The problem they were solving was long-lived transactions holding database locks for unacceptable periods. Their answer: break the work into sub-transactions, each committing independently, and give every one of them a compensating transaction that semantically undoes it. If step four fails, you run the compensations for three, two and one in reverse. The idea is about how you recover when you cannot roll back.

Process Manager comes from Hohpe and Woolf’s Enterprise Integration Patterns. A central component receives events, keeps the state of an in-flight sequence, and decides which step to trigger next. They contrast it with a Routing Slip, where the itinerary is computed up front and travels with the message. This idea is about who decides what happens next.

Those are answers to different questions, which is why the versus framing you see everywhere is wrong. You can have a choreographed saga, where each step emits an event the next step listens for and compensations chain backwards, with no central component anywhere. You can have a process manager that sequences six steps and has no compensation logic at all, because every step is retryable. And you can have both, which is what most people actually need and what the microservices literature confusingly labels an “orchestration-based saga”.

The practical cost of the muddle is that teams adopt the word saga, build a process manager, and then never write any compensations, on the understanding that the pattern handed them recovery. It did not hand them anything of the sort, and the compensations are still sitting there waiting to be written by somebody.

When this is worth it

The clearest signal is a step that cannot complete in the request that started it. Carrier C returns a 202 and a webhook arrives minutes later, so something has to exist in the meantime that knows a booking is outstanding, what it was for, and what should happen when the answer comes. A status column can record that you are waiting. It cannot record what you are waiting for, or what to do if the answer never comes.

Failure that means undoing rather than retrying pushes you the same way. Retryable failure needs a queue, not a process manager. But if failing at step five means you have to go and release capacity you reserved at step two, then something has to remember that step two happened and know how to reverse it, and that something is either a process manager or a lot of scattered conditionals pretending not to be one.

Accumulated state is the third signal. When step five needs something step two learned, your options are to thread it forward through every intermediate step, to go back to the carrier and ask again, or to give the process somewhere to keep its own working memory, and that last option is more or less the definition of the thing this article is about.

When it is not

A linear sequence of retryable steps does not need this. If the answer to every failure is to try again and there is no compensation to perform, chain your commands and go home. That is a queue, and it is the right tool.

Two or three steps do not need it either, since the if-chain at the top of this article is perfectly serviceable at that size and everything below costs more than it returns.

The one I would argue about longest is using this for something that is really a workflow product. If the business wants to reorder the sequence without a deploy, with approval gates and something visual to edit, then what you are building is a BPM engine, badly, and there are people who sell those. What a process manager suits is a sequence your engineers own outright.

Building it in Tempest

The process gets to be a thing, rather than a column on something else.

use Tempest\Database\IsDatabaseModel;
use Tempest\DateTime\DateTime;
final class ConsignmentBooking
{
use IsDatabaseModel;
public string $consignmentId;
public BookingStep $step;
public DateTime $enteredStepAt;
public ?string $quoteReference = null;
public ?string $capacityReservation = null;
public ?string $carrierBookingId = null;
public bool $requiresCustoms = false;
}

A few details there are worth copying rather than improvising. Use Tempest’s own DateTime rather than PHP’s DateTimeInterface, since that is what the clock hands you and mixing the two means converting at every boundary. Both are supported, with a caster for each, so this is a consistency argument rather than a compatibility one. And make BookingStep a backed enum because EnumCaster works on backed cases. There is no #[Table] attribute because you do not need one: table names default to the pluralised snake_case of the class name, so this lands on consignment_bookings by itself.

One constraint to know before you design the schema. IsDatabaseModel supplies its own PrimaryKey $id, and the docs are explicit that you therefore cannot use UUID primary keys alongside it. If you want the process keyed by a UUID, declare #[Uuid] public PrimaryKey $id yourself and skip the trait, which costs you the active-record helpers.

Before any of this goes near production, one caveat that applies to this article and to the first one in the series. Tempest’s database component is marked experimental and sits outside the backwards compatibility promise, and the maintainers have said publicly they are weighing a different approach to the ORM. The pattern here is framework-independent and will outlive whatever they decide. The specific calls may not.

Those nullable fields are the working memory, and they live on the process rather than on Consignment, so your domain object stops accumulating columns that matter for ninety seconds during booking and mean nothing at all afterwards.

enteredStepAt is the field that earns its place fastest. It is what lets you answer the question the status column could not: which bookings have been stuck in BOOKING_SUBMITTED longer than Carrier C’s stated response time. That query is your entire operational visibility into this process, and it costs one column.

Each step is a command handler:

#[CommandHandler]
public function handle(SubmitBookingToCarrier $command): void
{
$booking = query(ConsignmentBooking::class)
->select()
->whereField('consignmentId', $command->consignmentId)
->first();
if ($booking === null) {
throw new BookingProcessWasMissing($command->consignmentId);
}
$reference = $this->carriers->for($booking->carrier)->submit($booking);
$booking->update(
step: BookingStep::BOOKING_SUBMITTED,
carrierBookingId: $reference,
enteredStepAt: $this->clock->now(),
);
}

update() writes immediately. It validates the parameters, issues an UPDATE filtered on the primary key, and then copies the values onto the instance, so there is no separate save() to remember and no chance of the in-memory object disagreeing with the row.

The null check is not defensive padding. first() returns mixed, the process row genuinely can be absent if somebody deleted it or the command outlived it, and a fatal on null->carrier inside a relay process is a considerably worse way to find out. Match the field name to the property name too, since the column mapping follows your property names rather than converting them for you.

And the decision about what runs next lives in exactly one place:

final readonly class ConsignmentBookingProcess
{
public function advance(ConsignmentBooking $booking): void
{
$next = match (true) {
$booking->step === BookingStep::QUOTED
=> new ReserveCapacity($booking->consignmentId),
$booking->step === BookingStep::CAPACITY_RESERVED
=> new SubmitBookingToCarrier($booking->consignmentId),
$booking->step === BookingStep::BOOKING_ACCEPTED && $booking->requiresCustoms
=> new FileCustomsDeclaration($booking->consignmentId),
$booking->step === BookingStep::BOOKING_ACCEPTED
=> new GenerateLabel($booking->consignmentId),
default => null,
};
if ($next !== null) {
command($next);
}
}
}

Which looks suspiciously like the if-chain we started with, and there is no point pretending otherwise. The difference is not that the branching disappeared, because branching is inherent to the problem. The difference is that the branching is now the only thing in this class, it operates on a model that holds the process state rather than on your domain object, and it emits commands instead of calling methods, so every step is independently retryable, testable and observable.

The asynchronous step

Carrier C is the reason this is not just tidier code.

SubmitBookingToCarrier finishes without knowing whether the booking succeeded. The process sits in BOOKING_SUBMITTED, and the answer arrives later at an endpoint that has no idea any of this is going on:

#[Idempotent]
#[Post('/webhooks/carrier-c')]
public function __invoke(CarrierCWebhookRequest $request): Response
{
command(new RecordCarrierBookingOutcome(
carrierBookingId: $request->carrierBookingId,
accepted: $request->accepted,
));
return new Ok();
}

The webhook does not advance anything itself. It records the outcome as a command, and the handler for that command moves the process on and calls advance(). The controller stays ignorant of the sequence, which is what lets the sequence change without touching it.

The #[Idempotent] is not decoration, for the reasons in article four. Carrier C redelivers, and a redelivered acceptance that advances the process a second time generates two labels.

Compensation, which nobody writes

Now the failure that started all this. Label generation fails after the booking was accepted, and Carrier C is expecting a parcel.

public function compensate(ConsignmentBooking $booking): void
{
$completed = $booking->completedSteps();
foreach (array_reverse($completed) as $step) {
match ($step) {
BookingStep::BOOKING_ACCEPTED
=> command(new CancelCarrierBooking($booking->carrierBookingId)),
BookingStep::CAPACITY_RESERVED
=> command(new ReleaseCapacity($booking->capacityReservation)),
default => null,
};
}
}

This is the saga part, and writing it is not optional if you took capacity from somebody. Note that it needs completedSteps(), so your process has to record what it has done rather than only where it is. A single step field tells you the current position; compensation needs the history. That is a schema decision you make at the start or regret later.

Compensations also fail, and there is no compensation for a failed compensation. At some point the honest answer is a human, and the process needs a terminal state that means exactly that, with enough context stored on it that the human can act.

What it costs

The obvious one is that a process worth managing is a process worth persisting, so you have a new table, migrations, and rows that outlive the requests that created them. Rows in a non-terminal state are work in progress, and work in progress needs sweeping. Bookings stuck in BOOKING_SUBMITTED for a day will not resolve themselves.

Testing gets more involved, though mostly in ways that are good for you. You cannot assert the whole flow in a single call any more, so you end up testing each handler on its own and then testing advance() as a pure function from state to next command, which is a far better test than anything the if-chain permitted, and also a good deal more test code than you were writing before.

Debugging spreads out. A failure that used to be one stack trace is now a process in some state, a command that failed somewhere, and a relay that may or may not have retried it, and reconstructing the story means reading a row rather than a log. This is the cost that surprises people, and enteredStepAt plus a recorded history is most of the mitigation.

There is a subtler one worth naming. Once a process exists, everything wants to be part of it. Notifications, analytics, that thing marketing asked for. The sequence grows from six steps to fourteen, and a component whose only job was deciding what happens next becomes the place all your business logic lives. Keeping every step as a command helps, since a step that has started doing its work inside the process class is easy to spot in review.

Where this leaves us

If you have a status column with double-digit values and a method that switches on it, the pattern you are missing is probably a process manager. Give the process its own model, let it hold what it learns, record when it entered each step, and keep the next-step decision in one function that only does that.

Then, separately, decide whether you need compensation, because that is the saga question and it is not answered by any of the above. If you took something from somebody in step two that has to be given back when step five fails, you have to write that yourself, and no amount of correct sequencing will do it for you.

Next in the series: your consumer receives an event with an ID, then immediately calls back for the record. Event-Carried State Transfer, and the notification system that generates more traffic than it saves.

Part of a Series

The Second Pattern

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

View Full Series

Share

XLinkedIn

Related

Keep Reading

All posts →