Ground Truth

Model the Trade, Not the Screens

Mostafa DhouibMostafa Dhouib·11 min read
The short answer

When quote, acceptance and settlement exist as three screens calling services ad hoc, the acceptance and the trade record can disagree, and a double tap can execute twice. An explicit state machine with defined transitions makes the impossible combination unrepresentable rather than merely unlikely. An append-only record of transitions lets settlement and reporting be projections of one history instead of two copies that drift apart.

Model the Trade, Not the Screens

The short answer. A quote-to-trade platform is usually built as the screens a user walks through. A quote screen, an acceptance screen, a settlement screen, each calling whichever services it needs. The trade then has no single representation, and two specific failures follow. The acceptance record and the trade record can disagree, because they are written by different code paths and nothing forbids the combination. And a second tap on accept can execute a second trade, because there is no state the first tap moved out of. Modelling the trade as an explicit state machine with named transitions makes those combinations unrepresentable instead of unlikely, and an append-only record of the transitions lets settlement and reporting both be projections of one history.

The screens are what the client describes, because the screens are what they see. So the backlog becomes a list of screens, each gets a controller, each controller calls the services its page needs, and the trade exists as a side effect distributed across all of them. That architecture survives the demo. It does not survive the first disputed trade.

Three screens, no trade

Here is the shape as it usually arrives. The quote screen asks a pricing service for a price and writes a quote row. The acceptance screen writes an acceptance row and calls an execution service. The settlement screen reads the execution result, writes a settlement row, and sends a confirmation.

Each screen works. Each row is written correctly by the code that writes it. And yet the system permits states that cannot exist in the world.

An acceptance row with no corresponding executed trade, because the execution call failed after the acceptance was committed. An executed trade with no acceptance, because the execution service was called directly during an incident and somebody never wrote the acceptance back. Two executed trades against one quote. A settlement row referring to an amount that disagrees with the trade, because it was computed from the quote rather than from the execution.

None of these is a bug in a function. Every one of them is a combination of rows that the data model allows. The code happens not to produce them on the paths that get tested, which is a different thing from them being prevented.

Acceptance with no executed tradethe execution call failed after the acceptance was committed
Execution with no acceptancethe execution service was called directly during an incident and nobody wrote the acceptance back
Two executions against one quotethere was no state the first accept moved out of
Settlement that disagrees with the tradethe amount was computed from the quote rather than from the execution
Each row written correctlyby the code that writes it, which is why nothing looks wrong in review
Point at the trade. If the answer is a join across three tables plus judgement about which one wins, there is no trade in the system.
FigureNone of these is a bug in a function. Each one is a combination of rows that the data model permits, written by a different screen's code path, and the tested paths happen not to produce them. Happening not to produce a state is a different property from forbidding it.

The diagnostic question is simple and it is worth asking of any system like this. Point at the trade. Not the quote, not the acceptance, not the settlement. The trade, as one thing, with one identity and one current state. If the answer involves joining three tables and applying judgement about which one wins when they disagree, there is no trade in the system.

The state machine is the model

The trade is a thing that moves through states. Write the states down and the transitions between them, and the model stops being a matter of opinion.

A workable set looks like this. Quoted, meaning a price has been produced and is valid until an expiry. Accepted, meaning the counterparty has committed and the platform is bound. Executed, meaning the trade exists as a position. Settled, meaning value has moved. Plus the terminal branches: expired from quoted, rejected from quoted, failed from accepted, cancelled where business rules permit it.

The transitions are what carries the rules. Quoted to accepted requires the quote to be unexpired and to have no prior acceptance. Accepted to executed requires an accepted trade with no prior execution. Executed to settled requires an execution. There is no transition from quoted to executed, which means a trade cannot be executed without an acceptance, not as a matter of discipline but as a matter of the transition not existing.

Three properties follow, and they are the point of the exercise.

Every state the trade can be in has a name. There is no implicit state consisting of a row in one table and no row in another. A trade whose execution call failed is in a named state, failed, which has defined behaviour and shows up in operational queries. Under the three-screen model, that trade is in no state at all, which is why nobody notices it until a counterparty calls.

The invalid combinations are unrepresentable. Accepted-but-not-executed is a legitimate state in the machine, which is correct, because execution is asynchronous and there is a real moment when that is true. Executed-but-not-accepted is not a state in the machine, so nothing can produce it. The difference between unlikely and unrepresentable is the entire difference between a system you can reason about and a system you have to investigate.

The double tap has somewhere to fail. The second accept attempt arrives and asks for a transition from quoted to accepted on a trade that is no longer quoted. There is no such transition from the accepted state, so the attempt is refused. This has to be enforced at the write, by a conditional update on the current state or a uniqueness constraint on the transition, rather than by reading the state and then deciding, because reading and then deciding leaves a window in which two requests can both read quoted. The state machine supplies the rule. The database has to supply the atomicity.

The gate
A second accept arrives. Is the trade still in quoted when the write executes?
The transition applies
Quoted to accepted exists, the quote is unexpired, and there is no prior acceptance. This is the first tap.
There is no such transition
Accepted has no edge back to accepted, so the attempt is refused rather than executed a second time.
Enforce it with a conditional update on the current state or a uniqueness constraint on the transition. A read followed by a decision is not enforcement.
FigureThe double tap needs somewhere to fail, and a state machine gives it one. The second attempt asks for a transition out of a state the trade has already left, so there is no transition to apply. The machine supplies the rule and the database has to supply the atomicity, because reading the state and then deciding leaves a window in which two requests both see quoted.

Append-only transitions, and two projections

The state machine gives you correctness about what can happen. Recording the transitions gives you an answer to what did happen, and it removes a second class of divergence.

Store each transition as a row that is never updated. From state, to state, timestamp, actor, the reason, and a reference to the externally meaningful identifier if one exists. The trade's current state is the to-state of its latest transition, or a cached column maintained from the same source and verified against it.

Two consequences matter more than the auditability, which is the one usually cited.

The first is that settlement and reporting stop being separate copies. In the three-screen build, settlement reads its own rows and reporting reads its own rows, and the two are populated by different code written at different times. They disagree eventually. The disagreement is discovered when a figure in a report does not match a figure on a confirmation, and resolving it means deciding which of two equally plausible records is right. With transitions as the record, both become projections over the same history. Settlement is a fold over transitions that produces what has to move. Reporting is a fold over transitions that produces volumes and positions by period. They can be rebuilt, and rebuilding them from the same input has to produce the same output, which is a property you can test in the suite rather than an assurance.

The second is that a replay is possible. When a projection is wrong, because a rule was misimplemented, the repair is to fix the fold and recompute, not to write a migration that patches rows to values somebody believes are correct. That difference shows up as weeks of effort over the life of a platform.

There is a condition on all of this. The transitions have to be the only way state changes. One feature that updates a status column directly, added later by somebody in a hurry, and the history is no longer complete, and every projection built on it is quietly wrong from that date. The practical defence is that the status column is not writable by application code, only by the transition path, and a scheduled check recomputes each trade's state from its transitions and alerts on any mismatch.

What this costs

More code, and more code of a kind that reviewers find tedious. A transition table, a guard per transition, an enumerated state type, and the discipline of routing every change through one path even when a direct update would be two lines.

It also costs flexibility in the first weeks. Adding a state to a machine with recorded transitions and projections over them means thinking about what existing trades do, whereas adding a column means adding a column. That friction is the mechanism working rather than the mechanism failing, but it is experienced as slowness and it needs explaining to whoever is watching velocity.

The payoff is not evenly distributed either. On a platform where the trade is the business, it pays back the first time a counterparty disputes an execution and the answer is a trace rather than a reconstruction. On a system where nothing irreversible happens, it is over-building.

The honest limit

A state machine inside one service does not constrain the outside world. If execution happens at an external venue, the authoritative state of the trade lives there, and your machine holds a belief about it. The gap between the belief and the fact is bridged by reconciliation against the venue, not by the machine, and a platform that models its own states carefully while never comparing them to the counterparty's records has solved the easier half of the problem.

The transition log also does not make the transitions correct. A guard that permits something it should forbid will permit it consistently and record it neatly, and the audit trail will show a clean history of a wrong rule being applied. Modelling buys you representability and traceability. It does not buy you correct business rules, and the rules are where the money usually goes missing.

Finally, this is a shape rather than a measured outcome. I have watched the three-screen version fail in the ways described and I have built the state machine version, but I am not claiming a figure for defects avoided. Treat the argument as mechanical rather than empirical, and test it in your own system by trying to insert the impossible combination. If the schema accepts it, the model is not doing the work.

FAQ

What goes wrong when quote, acceptance and settlement are separate screens? The trade has no single representation, so the data model permits combinations that cannot exist in the world. An acceptance with no executed trade, an execution with no acceptance, two executions against one quote, a settlement amount that disagrees with the execution. None is a bug in a function. Each is a row combination nothing forbids, which the tested paths happen not to produce.

How does a state machine prevent a double tap from executing twice? The second attempt requests a transition out of a state the trade has already left, and no such transition exists, so it is refused. The machine supplies the rule, but the atomicity has to come from the database: a conditional update that moves the state only if it is still the expected one, or a uniqueness constraint on the transition. Reading the current state and then deciding leaves a window where two requests both see the old state.

What does unrepresentable mean here, as opposed to unlikely? Accepted-but-not-executed is a named state in the machine, because execution is asynchronous and there is a genuine moment when it is true. Executed-but-not-accepted is not a state in the machine, so no code path can produce it. Unlikely means the current code happens not to create it. Unrepresentable means nothing can, including code written next year by someone who has not read this.

Why record transitions instead of just the current state? Because settlement and reporting then become projections over one history rather than two separately populated copies that drift apart. Both can be rebuilt from the same input, and identical inputs producing identical outputs is a property you can assert in the test suite. It also means a wrong projection is fixed by correcting the fold and recomputing, rather than by a migration that patches rows to values somebody believes are right.

What breaks the append-only guarantee in practice? A feature that writes the status column directly. One of those, added later by somebody in a hurry, and the history stops being complete, and every projection over it is quietly wrong from that date onward. The defence is that application code cannot write the status column at all, only the transition path can, plus a scheduled job that recomputes each state from its transitions and alerts on mismatches.

Does an internal state machine remove the need for reconciliation? No. If execution happens at an external venue, the authoritative state lives there and the machine holds a belief about it. Closing the gap between the belief and the fact requires comparing your records to the counterparty's on a schedule. A platform that models its own states carefully and never reconciles them externally has solved the easier half of the problem.

Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program