Skip to content
Architecture Programming

Transaction Script Service

By Romi Fadli, S.Kom. 8 min read Software Design
On this page
One endpoint procedure as a column of ordered steps, a request entering at the top and the answer leaving at the bottom, each step calling its own concern module beside it

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

OriginMartin Fowler's Transaction Script pattern, with my own conventions for Python services
I use it forSmall Python services with one job that have to work quickly
I do not use it forServices whose business rules keep growing, or that need tests without their infrastructure
LanguagesPython, with FastAPI or Flask
Where Transaction Script Service sits among the architectures I use.

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:

bash
server.py              # thin entry point: build the app, start uvicorn
src/
├── api.py             # create_app(): middleware, error handlers, one procedure per endpoint
├── config.py          # load configuration once
├── telemetry.py       # traces, logs, and metrics, set up in code
├── fetchers.py        # one module per concern, named after what it does
├── transform.py
└── verifier.py

server.py
routers/               # endpoints grouped by audience: public, internal
services/              # concerns with state or clients: cache, storage, the core engine
utils/                 # configuration, responses, small helpers

An endpoint reads as the list of steps it performs, and each step calls a concern module directly:

python
@app.post(f"{prefix}/process")
async def process(request: Request, payload: ProcessPayload):
    request_id = new_request_id()
    token = require_bearer(request)
    secret = decrypt_secret(payload.secret_enc, key=cfg.secret_key)
    document = await fetch_document(session, str(payload.document_url))
    result = await run_in_threadpool(transform, document, secret)
    await upload_bytes(session, str(payload.upload_url), result)
    return ok(request_id, {"size": len(result)})
ConceptWhere it lives
The procedure for one requestone endpoint function in api.py or a router
Work that belongs to one concerna module named after it, such as fetchers.py or verifier.py
Configurationone object, loaded once and imported where needed
Long-lived clients: HTTP sessions, cachesmodule-level instances, created once
Telemetryits own module, initialised before the app and its routers are built
Entry pointserver.py, which only builds the app and starts the server
Where each concept lives in a transaction script service.

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:

AreaQuestion I askSource
ProceduresCan I read every step of a request, in order, in one endpoint function?one procedure per request
ConcernsIs each module named after one concern, and does it stay out of the endpoint layer?the concept table
CyclesDo concern modules import each other only in one direction?the rules
Shared stateAre module-level instances limited to configuration and clients?the rules
Blocking workDoes every heavy step run off the event loop?the rules
BundlingDoes the bundled executable still carry the instrumented libraries' metadata and every dynamic import?the rules
GrowthIs the same rule now written in more than one procedure?where I do not use it
The questions I check a transaction script service against.

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

About the author

Photo of Romi Fadli

Romi Fadli, S.Kom.

Solution Architect · Backend Engineer · Embedded Systems

In my day job I work as a solution architect. I design the system, lead the team that builds it — quality assurance, backend developers, a UI developer, DevOps and DevSecOps — and write the most critical modules myself, such as a certificate authority, a long-term PDF signer, and the core API of a personnel system.

I work across the whole stack that a running system actually needs. On the hardware side that means PCB design, microcontrollers, and embedded firmware — including RISC-V boards running FreeRTOS and Embassy, speaking MQTT over mutually authenticated TLS on a microcontroller with 64 KB of SRAM.

Most of my day-to-day work is software: Rust backends and API modules built on Clean Architecture, an OAuth2/OpenID Connect authorization server written from scratch, PAdES document signing, and certificate authorities — root CAs, timestamping authorities, and OCSP responders. I also build the frontends that sit on top of them.

Running all of that means infrastructure: Docker Compose stacks, networking, Mikrotik and RADIUS, observability with OpenTelemetry, and a steady habit of writing command-line tools and utilities for Linux when the existing ones do not fit.

Share

Enjoyed this?

Get an email when a new article is published.

Referenced by

A locked offline host holding the root key, a signed revocation list crossing an air gap to a small publisher server, which serves the root certificate and the list to a validator

A tiny publish-only service that keeps a root CA reachable without making it touchable: it serves the root certificate and its revocation list, while the root key and every signature stay on an offline host — reusable by any project with its own CA.

7 min read Read more →
Three camera frames of a face, facing forward, turned right, and turned left, each checked off, flowing into locked private storage that a reviewer looks into

A Python service that asks a person on camera to face forward, turn right, and turn left, captures one sharp frame per pose, and keeps the three as private evidence for a human reviewer — proving a live person was present, not who they are.

10 min read Read more →
A signed PDF with a QR stamp, wrapped in four nested frames for the signature, its timestamp, the embedded validation data, and the archive timestamp

A Python service that signs PDFs to PAdES B-LTA with a visible stamp — embedding the timestamp, certificates, and revocation proof, then an archive timestamp — so signatures stay verifiable long after their certificates expire, and verifies signed PDFs.

10 min read Read more →

Related Articles

Three camera frames of a face, facing forward, turned right, and turned left, each checked off, flowing into locked private storage that a reviewer looks into

A Python service that asks a person on camera to face forward, turn right, and turn left, captures one sharp frame per pose, and keeps the three as private evidence for a human reviewer — proving a live person was present, not who they are.

10 min read Read more →
A signed PDF with a QR stamp, wrapped in four nested frames for the signature, its timestamp, the embedded validation data, and the archive timestamp

A Python service that signs PDFs to PAdES B-LTA with a visible stamp — embedding the timestamp, certificates, and revocation proof, then an archive timestamp — so signatures stay verifiable long after their certificates expire, and verifies signed PDFs.

10 min read Read more →
Before: one Colab runtime holds both the interface and the worker. After: the interface on the laptop, the worker alone in Colab, both reaching shared storage for data and a small broker for triggers

Each project began with its interface served from inside the borrowed Colab runtime, through Colab's own proxy — until Colab warned against it. Moving the interface out made the runtime a pure worker, and left two codebases held together by nothing but an agreement.

18 min read Read more →