Transaction Script Service
On this page
- At a glance
- The idea in brief
- A. One procedure per request
- B. Logic and flow live together
- C. Enough while the logic is simple
- Why I chose it
- My conventions
- How I check a module against it
- Where I do not use it
- When the rules outgrow one procedure
- When the service is written in Rust
- References
- Terms
Transaction Script Service is the shape I give a small Python web service with one job: each endpoint is a single procedure that carries a request from start to finish, and everything that procedure needs — fetching, transforming, verifying, configuration, telemetry — sits beside it in modules named after what they do. The pattern underneath is Martin Fowler's Transaction Script, from Patterns of Enterprise Application Architecture; the conventions around it are my own.
It is one of the architectures I use, and I choose it deliberately for services whose whole purpose is one pipeline. When the rules inside that pipeline start to grow, or when I need to test them without the outside world, I move the service to Clean Architecture.
At a glance
| Origin | Martin Fowler's Transaction Script pattern, with my own conventions for Python services |
|---|---|
| I use it for | Small Python services with one job that have to work quickly |
| I do not use it for | Services whose business rules keep growing, or that need tests without their infrastructure |
| Languages | Python, with FastAPI or Flask |
The idea in brief
A. One procedure per request
Fowler describes a transaction script as a procedure that takes the input of one request, does all the work, and returns the result. Each step is a line in that procedure, in the order it happens: check the caller, read the input, fetch what is needed, do the work, store the result, answer.
B. Logic and flow live together
There is no separate model of the domain that the procedure delegates to. The decisions are made where the flow passes through them, which makes a single request easy to follow and makes rules shared by many requests harder to keep in one place.
C. Enough while the logic is simple
Fowler presents the pattern as the natural choice when the logic is small, and warns that it turns into duplicated, tangled procedures as the logic grows. The same advice draws the boundary of where I use it.
Why I chose it
The whole request fits on one screen. When something fails in production, I want to open one function and read, from top to bottom, everything the service did with that request. A transaction script gives exactly that: no jumps through interfaces, factories, or layers before reaching the line that matters.
It is the fastest way to a working service. A service built around one pipeline — take a file and transform it, take a stream and judge it — needs the pipeline first. Writing it as one procedure lets the first version answer real requests as soon as the pipeline works, and the structure does not get in the way of the libraries that do the heavy work.
It bundles into one binary without surprises. I ship these services as a single executable built with PyInstaller. A flat set of modules imported directly is easy for the bundler to discover, and the few dynamic imports it misses are listed explicitly in one place.
It is honest about what the service is. A service with one job does not need a domain model it would never use. Adding layers for a pipeline of five steps would make the code look more maintainable than it needs to be while making every change longer.
My conventions
A service of mine that follows this architecture has one of two trees. The first is a single flat package; the second splits the same modules into three folders when there are several routers:
An endpoint reads as the list of steps it performs, and each step calls a concern module directly:
=
=
=
= await
= await
await
return | Concept | Where it lives |
|---|---|
| The procedure for one request | one endpoint function in api.py or a router |
| Work that belongs to one concern | a module named after it, such as fetchers.py or verifier.py |
| Configuration | one object, loaded once and imported where needed |
| Long-lived clients: HTTP sessions, caches | module-level instances, created once |
| Telemetry | its own module, initialised before the app and its routers are built |
| Entry point | server.py, which only builds the app and starts the server |
A few rules keep the procedures readable. An endpoint may be long, but it reads in the order things happen and does not hide steps in helpers that only it uses. Concern modules never import the endpoint layer and never import each other in a circle. Module-level instances are reserved for configuration and clients, never for request data. Blocking work, such as transforming a file or analysing an image, runs off the event loop. Telemetry is instrumented in code rather than by a launcher, because a bundled executable has no launcher, and the libraries being instrumented are bundled with their metadata, without which the instrumentation switches itself off without a word.
How I check a module against it
These are the questions I ask a transaction script service, taken from the conventions above:
| Area | Question I ask | Source |
|---|---|---|
| Procedures | Can I read every step of a request, in order, in one endpoint function? | one procedure per request |
| Concerns | Is each module named after one concern, and does it stay out of the endpoint layer? | the concept table |
| Cycles | Do concern modules import each other only in one direction? | the rules |
| Shared state | Are module-level instances limited to configuration and clients? | the rules |
| Blocking work | Does every heavy step run off the event loop? | the rules |
| Bundling | Does the bundled executable still carry the instrumented libraries' metadata and every dynamic import? | the rules |
| Growth | Is the same rule now written in more than one procedure? | where I do not use it |
Where I do not use it
When the rules outgrow one procedure
The signal is duplication: the same decision written into two endpoints, or a procedure that needs a comment to explain which branch applies to which case. At that point the rules deserve a home of their own, and the service needs tests that exercise them without a network, a cache, or a heavy library underneath. That is what Clean Architecture gives me, so that is where such a service goes: the concern modules become adapters behind ports, and each procedure becomes a use case that the tests can call directly.
When the service is written in Rust
A Rust service that has to work quickly gets a different first shape, with one folder per kind of code and one file per operation, because Rust's module system and compiler reward a different kind of flatness.
References
Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002): the Transaction Script pattern, and the Domain Model pattern he contrasts it with, including his discussion of when each one pays off. The conventions for structure, bundling, and telemetry described here are my own.
Terms
- Transaction script
- A way of organising logic in which each operation of the system is one procedure that does all its own work from start to finish.
- Domain model
- A way of organising logic in which the rules live in objects that represent the business, and procedures only coordinate them.
- Concern module
- A module that holds the code for one concern of the service, such as fetching files or verifying signatures, named after that concern.
- Event loop
- The single thread that an asynchronous Python server uses to handle many requests at once; blocking it stalls every request.
- PyInstaller
- A tool that bundles a Python program and everything it imports into one executable that runs without a separate Python installation.
Technologies covered
- Python
- FastAPI