I'm designing an MFA login flow with three states: sending an OTP by SMS, waiting for the user to enter the OTP, and minting a session. After sending the OTP, the flow automatically moves to the waiting state; invalid codes keep it there, while a valid code transitions to session creation.
I want the workflow to survive an API crash, so the current state must be stored in a database. My understanding is that the work performed during a transition should be committed in the same transaction as the state update. For sending the OTP, that would mean using a transactional outbox: write an SMS job to the outbox and persist the waiting state in one transaction, then let a separate worker send the message.
The part I'm unsure about is where the next state should be selected. If an entry or exit action writes the next state to the database, the state implementation appears to know details that normally belong to the state machine definition. Worse, the transition chosen by the machine and the state written by the action could potentially differ.
How should I structure this so the transition selected by the state machine, the persisted state, and the required side effects are coordinated atomically? I'm especially interested in how this should be modeled when using XState or a similar state-machine library.
2 Answers
The state being persisted should normally be the state the workflow is entering. The important part of the transaction is not just saving that state, but also saving all related bookkeeping and side-effect information that resulted from the transition.
For the SMS step, the transaction can record both the new state, such as `WaitingForOtp`, and an outbox entry saying that an OTP SMS needs to be sent. A separate worker processes the outbox. This makes the state update and the intent to send the message atomic, without requiring the database transaction to call the SMS provider directly.
If the process crashes afterward, restoring `WaitingForOtp` means the SMS has at least been recorded for delivery. If it crashes before that transaction commits, the workflow remains in the previous state and the outbox entry is absent. Either way, recovery sees a consistent database snapshot. The outbox worker should also be idempotent because delivery may be retried.
A state implementation does not necessarily need to be completely unaware of its possible exits. The state machine definition determines the transition rules, while a reusable state action can be configured with the destination or can return a transition result that the engine applies. The key is that application code should not independently guess and persist a next state behind the engine's back.
A practical design is to have the engine resolve the transition first, then execute one unit of application logic that persists the resolved destination state together with any domain changes and outbox records. In other words, treat the transition and its effects as one command handled by the persistence layer, rather than relying on an unrelated entry callback to update the workflow record.
Reusable actions can still be shared between workflows; they just receive the transition context or configured outcome from the machine definition. The database write should use the transition selected by the engine, not a separately hard-coded assumption.
That also keeps the state-machine definition as the source of truth. Entry actions can perform calculations or produce commands, but the persistence code should receive the actual transition result from the engine before committing it.

My concern is that many libraries put the work in an `onEntry` or `onExit` callback while the transition itself is defined elsewhere. That could allow the callback to persist a different next state than the machine actually selected. I want the persisted state and the executed transition to be controlled at the same level.