Transaction Script
One procedure per operation, top to bottom, doing the whole job. Often exactly right — and the criteria for when it stops being right are knowable in advance.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
When is a straightforward procedure per use case the correct design, and what specifically tells me it has stopped being?
Build order cancellation: check it is cancellable, release the stock reservation, refund the payment, send an email, write an audit row. One operation, five steps, and it needs to ship this sprint.
This is a small system, so write the procedure. One function, five steps, in order, with the checks inline. It is completely obvious to read and there is nothing to learn before changing it.
It does not break at eleven use cases — this is the important point and the one the pattern literature buries. It breaks at some later size, and the failure is gradual rather than sudden.
- It does not break at eleven use cases — this is the important point and the one the pattern literature buries. It breaks at some later size, and the failure is gradual rather than sudden.
- The first real crack is duplication between scripts: cancel, refund and return all need "is this order cancellable", and by the third one there are three subtly different versions (Duplicate Knowledge).
- The second is conditional accretion. Each new customer type, product type or channel adds a branch, and after two years the cancellation script is four hundred lines with nine flags and nobody can say which combinations are reachable (Boolean Flag Explosion).
- The third is testability. A script that does five things through five dependencies can only be tested by stubbing all five, so tests get slower and more brittle exactly as the rules get more interesting (Testing as Design Feedback).
- None of these appears in the first year, which is why the pattern is chosen correctly and then kept too long.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The team has three engineers and a two-week deadline; whatever is built must be readable by a contractor next month.
- The system currently has eleven use cases and four business rules in total.
- Two of the five steps call external systems that can fail independently.
- A cancelled order never remains stock-reserved.
- Money is refunded at most once per cancellation, regardless of retries.
- Whatever design is used, each rule is written in exactly one place.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The script owns the sequence of one use case and nothing else — it is the application layer, and it should stay thin enough to read in one screen.
- Rules shared by more than one script must be extracted somewhere both can call, whether that is a function, a value object or an entity method. Which one matters much less than that there is exactly one.
- External systems are reached through narrow interfaces so the script can be tested and so a failure of one is visible in the code (Volatile Dependencies).
- Nobody owns "keeping the eleven scripts consistent" — that is the responsibility a richer model would take on, and choosing a script means choosing to do it by hand.
- The script is the boundary of one use case. Its inputs are a request and its outputs are effects; that clarity is the pattern's main virtue.
- The seam that matters is between the sequencing and the rules. Keeping the rules as separate, called functions is what makes the later migration to a richer model cheap rather than catastrophic (Finding Seams).
- The boundary with the outside is where retries and idempotency live, and it belongs in the script rather than in the rules (Idempotency by Design).
The script, written sympathetically
Here is the version the pattern is usually caricatured as, written as well as it can be written. It is not bad code. A contractor can read it in ninety seconds, and the sequence of events in the business is visible in the sequence of lines.
The two design decisions inside it are the ones worth copying: the rule is a named function rather than an inline condition, and the effects are ordered so that a failure in the middle leaves a state a retry can recover from.
1export async function cancelOrder(orderId: OrderId, deps: Deps) {2 const order = await deps.orders.load(orderId)3 4 // the rule is a named function, so the return script can call it too5 const refusal = cancellationRefusal(order, deps.clock.now())6 if (refusal) return Err(refusal)7 8 // effects ordered so a mid-way failure is recoverable on retry:9 // release is idempotent, refund is keyed, email is last because it cannot be undone10 await deps.reservations.release(order.reservationId)11 await deps.payments.refund(order.paymentId, order.total, { key: `cancel:${orderId}` })12 await deps.orders.markCancelled(orderId)13 await deps.audit.record('order.cancelled', orderId)14 await deps.mailer.sendCancellation(order.email)15 16 return Ok()17}18 19// pure, testable, and the single home of the rule20export function cancellationRefusal(order: Order, now: Date): Refusal | null {21 if (order.status === 'delivered') return 'already-delivered'22 if (order.dispatchedAt && hoursSince(order.dispatchedAt, now) > 24) return 'too-late'23 return null24}The idempotency key on the refund is the line that separates a script that survives production from one that double-refunds during an incident. Note also what is absent: no OrderCancellationService class, no interface with one implementation, no repository abstraction beyond what the test needs. That absence is the design (YAGNI, With Its Bill Attached).
The criteria, stated so they can be checked
The usual advice — "use a domain model when the domain is complex" — is unfalsifiable and therefore useless in an actual decision. These criteria are checkable against a codebase in an afternoon.
Read the rows as a set: any one of them alone is weak evidence, and three of them together is a decision. The last row is the one that most reliably predicts trouble and is the least often looked at.
| Signal | Script is still right | A richer model is starting to pay |
|---|---|---|
| Rules shared across use cases | Each rule is used by one or two scripts, and an extracted function covers it. | The same rule is called from six scripts with slightly different pre-checks around it. |
| Variation inside one operation | A handful of branches, all reachable and all named. | Nested conditionals on customer type, channel and product type, with combinations nobody can enumerate (Boolean Flag Explosion). |
| Rules that interact | Rules are independent: each says yes or no on its own. | Rules modify each other — a discount changes which tax rule applies, which changes eligibility. A procedure cannot express composition. |
| Invariants spanning objects | Each rule concerns one thing at a time. | Something must stay true across several objects at every instant (Aggregates). |
| Stubs needed per test | Two or three. | Six or more, and adding a step to the script breaks unrelated tests (Mocking). |
| Where a new engineer looks for a rule | They find it in one grep, in the script that uses it. | They ask in Slack, because the rule is in three places and they cannot tell which is authoritative. |
Choosing, and choosing again later
The decision is not binary and pretending it is causes most of the damage. The middle option below — scripts for sequencing, a model for the parts with rules — is where a large fraction of well-run systems actually live, and it is rarely named as a choice.
Whatever is chosen, write down the trigger. A design that drifted past its fit without anyone noticing is the normal outcome, and it is the one thing a written trigger reliably prevents (Decision Records).
How many rules are there, how many places use each one, and do the rules interact?
when Few rules, each used once or twice, no cross-object invariants. Integration services, admin tools, most internal systems.
cost Manual consistency. You are choosing to keep rules aligned by hand, which is fine while the rule count is small and everyone knows the codebase.
when A rule is now used by two or more scripts. The most common healthy state.
cost Almost nothing — a named pure function. This should be the default response to the second use, not a migration project.
when Rules interact, or invariants span objects, but orchestration is still per use case.
cost Two layers to learn and a mapping between them. This is where most mature systems settle, and it is a legitimate destination rather than a way-station.
when Invariants span objects, are contested, and are violated in production by paths nobody remembered.
cost Vocabulary, indirection, mapping, contention. Repays only at genuine rule complexity with access to someone who knows the rules (When Domain-Driven Design Does Not Pay).
when Almost never.
cost You discard eleven working use cases to fix three, and the half-migrated state lasts longer than anyone predicts (The Risk in a Rewrite).
How to build it
Most important first.
- Write it as a straight sequence and resist adding structure that no requirement has asked for. A readable procedure is a legitimate design output (KISS: Simplest for the Requirements You Have).
- Extract a rule the moment a second script needs it — not the first time, and not never. That is the rule of three applied to knowledge rather than to code shape (The Rule of Three).
- Keep the rules pure and the effects at the edges of the script, so the rules can be tested without stubs even while the script cannot (Functional Core, Imperative Shell).
- Make each external step idempotent and ordered so that a retry after a partial failure is safe, because a five-step script with two remote calls will half-fail in production (Partial Failure).
- Watch two numbers: how many branches the script has, and how many scripts share a rule. Those are the revisit triggers, and writing them down turns a drift into a decision (Revisit Triggers).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding a twelfth use case: one new file, no changes to any existing one. This is the pattern's strongest property and it is genuinely excellent — additive change with zero blast radius.
- Adding a rule that affects three existing scripts: three edits plus finding them, and the three may end up subtly different. This is the cost that grows with rule count.
- Changing a rule already shared by an extracted function: one edit. Which is why extracting on the second use is the whole discipline.
- The change that eventually forces a rethink: a requirement that is about the relationships between rules — "these five rules apply in this combination for wholesale customers" — has nowhere to live in a procedure, and expressing it produces the four-hundred-line branch tree.
- The procedure is obvious to read and duplicative to maintain: you are trading a low learning cost now for manual consistency work later.
- Extracting rules early keeps consistency but starts building the model the pattern was chosen to avoid, so the discipline requires judgement rather than a rule.
- Because a script depends on everything it orchestrates, its tests need stubs from day one, which is a small but permanent friction.
What can go wrong
- The rule is extracted into a
helpersorutilsmodule rather than onto a concept, so it is findable by nobody and grows unrelated neighbours (The Utility Dumping Ground). - The script grows conditionals for every variation and becomes the thing everyone is afraid to change, which is where the "anti-pattern" reputation comes from (Divergent Change).
- A partial failure leaves the order cancelled and the stock still reserved, because the five steps were written as if they all succeed (Designing the Happy Path Last).
- The mitigation fails too: wrapping all five steps in one database transaction does not help, because two of them are external calls that no transaction can roll back (External Calls Inside a Transaction in Backend).
- The script depends on everything it orchestrates — repositories, payment client, mailer, audit — which is why its dependency list is the honest measure of how much it is doing.
- Rules extracted from the script depend on nothing, which is what makes them testable and reusable.
- Nothing depends on the script. It is a leaf, and that is a genuine architectural advantage: a script can be rewritten wholesale with no ripple (Reversible and Irreversible Decisions).
- "Transaction script is a beginner pattern." It is the correct pattern for a large class of systems, including systems that are large in every dimension except rule complexity. Reporting backends, integration services and admin tools are frequently best served by it forever (When Domain-Driven Design Does Not Pay).
- "Once we have ten scripts, we need a domain model." The count of use cases is not the trigger. The triggers are rules shared across scripts and variation inside them — a hundred simple scripts are fine (Change Amplification).
- "A script means no structure." A script with rules extracted into named pure functions has real structure; it just does not have objects. That distinction is what makes the eventual migration incremental (Incremental Migration).
- "Wrap everything in a transaction and partial failure goes away." Not for external calls. Idempotency and ordering are the design, and a transaction is only part of it (Partial Failure).
- long-parameter-list
- divergent-change
Testing it, and how it ages
- Test extracted rules directly as pure functions; this is where most of the value is and it costs almost nothing.
- Test the script itself at the use-case boundary with stubs for the external systems — one happy path and the partial-failure cases, which are the ones that actually happen (Where a Test Must Be Real).
- Explicitly test the retry: run the script twice against the same order and assert one refund. A script that has never been tested for double execution will double-refund in production (Idempotency by Design).
- Count the stubs a script needs as a design signal. Past four or five, the script is doing too much and the tests will tell you before the code does (Mocking).
- Scripts age well for a long time and then decline sharply. The decline is driven by rule count and variation count, not by lines of code or by age.
- The healthy path out is gradual: rules move from scripts into value objects and entities one at a time, and the scripts stay as thin sequencing. Many mature systems are exactly this hybrid and it is a good place to be.
- The unhealthy path is a rewrite into a rich model all at once, which throws away eleven working use cases to solve a problem that affects three of them (The Risk in a Rewrite).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALA procedure per use case works in any language and paradigm; the differences are only in how easy the language makes extracting a rule later, which affects the migration cost rather than the initial choice.
- DOMAIN-SPECIFICThe pattern holds indefinitely where operations are independent and rules are few — integration services, admin tools, most internal reporting. It degrades quickly where rules interact combinatorially, as in insurance underwriting, tax or scheduling, because a procedure has no way to express "these rules compose".
- SCALE-SPECIFICAt three engineers a script is legible and consistency is maintained by everyone knowing everything. Past roughly ten engineers, "everyone knows the rule is also in the return script" stops being true, and duplicated rules start diverging within a quarter.
- CONTESTEDThe strongest opposing view is that the crossover point is a myth: teams that plan to migrate from scripts to a model almost never do, so systems that will live for a decade should pay the modelling cost up front while it is cheap. The evidence for this is real — half-migrated codebases are common and unpleasant. The counter-evidence is equally real: most systems built "properly" up front are over-modelled for rules that never arrived.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the number of stubs a use case needs is one of the most reliable early signals that a design has outgrown its shape, which makes test friction a design instrument rather than a nuisance.