How it is built
Architecture overview
An e-prescription system moves one legally meaningful order, "dispense this drug to this patient", from a prescriber's software to a pharmacy's software without paper and without losing, altering or double-filling it. Prescribers (a health-record or practice system), pharmacies (a dispensing system) and a network operator between them are involved. The operator is either a hub that routes messages (the US model, Surescripts being the best-known operator) or a central repository that holds each prescription as a record the parties act on (the German gematik E-Rezept). Either way four jobs appear: represent the order in a shared model with coded drugs, prove who signed it, drive it through a small life cycle exactly once, and keep an audit trail that survives a dispute. The one idea that makes it work: the prescription is a signed, immutable document plus a state machine that lives on the operator. The document is never edited after signing; only the state moves (ready, in progress, completed), guarded by a capability the patient hands to one pharmacy.
Parts:
- Prescription model — the order as typed resources —
fhir-resource-model(no page yet) - Drug vocabulary — coded drugs, mapping between catalogues —
terminology-code-systems(no page yet) - Prescriber-side checks — warnings at order entry —
event-condition-action-rules(no page yet), alternative Rete rules engine - Identity matching — find the right patient —
record-linkage(no page yet),fuzzy-string-matching(no page yet) - Trust and signing — who is the caller, who signed the order —
x509-certificates(no page yet),mfa(no page yet), Digital signatures (Ed25519), Hardware security module (HSM) - Transport — channels between prescriber, operator and pharmacy — TLS 1.3, Mutual TLS
- Prescription service — stores orders, runs their life cycle — Hierarchical state machine, Idempotency keys, Authorization models (RBAC, ABAC, ReBAC)
- Durability — never lose or fork an order — Write-ahead log, Primary-backup replication, Point-in-time recovery
- Delivery reliability — failed messages are retried without storms — Exponential backoff with jitter
- Audit — a record nobody can quietly rewrite — Tamper-evident audit log
- Operations — keep it up and prove it — Process supervision, Graceful draining,
hdr-histogram(no page yet), Deterministic simulation testing, Coverage-guided fuzzing
prescriber app (EHR) operator / prescription service pharmacy app
┌──────────────────────┐ mTLS/TLS ┌─────────────────────────────────┐ mTLS/TLS ┌─────────────────┐
│ order entry │─────────────►│ API (FHIR REST) │◄───────────│ fetch / accept │
│ rules (ECA) │ signed │ authn (certs) · authz (ABAC) │ dispense │ dispense / close│
│ terminology lookup │ order │ idempotency · state machine │ results │ drug catalogue │
│ patient match │ │ │ │ │ │ (ConceptMap) │
│ 2-factor + sign │ │ WAL ─► primary ─► standby │ └─────────────────┘
└──────────────────────┘ │ │ │ archive │
▲ │ audit log (Merkle, signed) │──► point-in-time recovery
│ access code / 2D code └─────────────────────────────────┘
└──────── patient (phone app or paper token) ──────────────────────► hands it to a pharmacy
How the parts fit together
Prescription model and vocabulary
The order is not free text. FHIR resources (fhir-resource-model, no page yet) give it a
shape: a MedicationRequest carries the status, the medication, the dosage and a dispenseRequest block with the
validity period and the number of refills allowed, plus a substitution block that says whether the pharmacy may swap
a generic (source: HL7 FHIR R5 MedicationRequest page). A MedicationDispense is the pharmacy's answer. Why FHIR and
not a pipe-delimited message such as HL7 v2 (hl7v2-message-parsing, no page yet)? Resources are typed, versioned
(ETag/versionId), searchable by named parameters, and one shape serves the prescriber API, the patient app and the
pharmacy API. The US network has long spoken NCPDP SCRIPT messages; a new national scheme such as the German one is
built directly on FHIR profiles.
The drug is a code, not a string. terminology-code-systems (no page yet) holds the code systems and value sets the
profile binds to: RxNorm for US clinical drugs, SNOMED CT for clinical concepts (both listed on the HL7 code-system
page). The prescriber UI uses $expand on a value set to offer only valid choices; the pharmacy uses a ConceptMap
($translate) to turn the code into the product in its own catalogue; $subsumes answers "is this product a kind
of the ingredient the rule names?". The contract is a coded drug plus the original display text, so a code the
pharmacy cannot map degrades to a readable line, not a rejection.
Prescriber-side checks
At order entry the prescriber software runs rules: interaction, duplicate therapy, allergy, maximum dose, "controlled
substance needs two-factor signing". event-condition-action-rules (no page yet) fits: the event is "order drafted"
or "changed", the conditions read the order and the medication list, the actions are warnings the prescriber must
acknowledge. A table of a few dozen rules needs nothing more; Rete rules engine pays off only when
thousands of rules share conditions. Rules run on the prescriber's side because only there can the prescriber react
before signing; the operator validates the shape of the order (the profile), not the clinical content.
Identity matching
Patient, prescriber and pharmacy directories must be matched; the patient case is the dangerous one. record-linkage
(no page yet) scores candidate pairs on name, birth date, address and identifiers with Fellegi-Sunter weights after a
blocking step; fuzzy-string-matching (no page yet) supplies the similarity for misspelt names. FHIR exposes the
operation as Patient/$match (source: HL7 FHIR Patient-operation-match). The contract: ranked candidates with a
score, never a silent merge, with two thresholds giving match, clerical review and no match, because attaching a
prescription to the wrong patient is a safety event while a missed match costs one phone call. Where the scheme
carries a patient identifier on every order (Germany's insurance number) matching shrinks to a lookup.
Trust and signing
Who may speak and who signed are two different questions.
- Who is calling: each organisation holds an X.509 certificate (
x509-certificates, no page yet), checked with Mutual TLS, so the service knows the caller is a registered system, not only that a token is valid. The channel is TLS 1.3. The German scheme adds an application-layer channel on top of TLS (the "VAU transport") so requests stay encrypted into the service's trusted execution environment instead of ending at a load balancer (source: gematik api-erp,authentisieren.adoc). - Who signed: in Germany a qualified electronic signature is made with the physician's health-professional card
through the connector; the service validates it and adds its own server-side signature to protect integrity
(source: gematik api-erp,
erp_bereitstellen.adoc). In the US, for controlled substances, 21 CFR 1311 requires two of three factors (known, owned, being), with a hard token separate from the computer if a token is used; completing that step is the signing, the application then digitally signs and archives the record, and an unsigned prescription must not be transmitted (source: eCFR 1311.115, 1311.140). This is the multi-factor pattern (mfa, no page yet) applied as a step-up at the moment of signing, not once per session. - Which algorithm: both regimes fix cryptography by regulation (the US text cites FIPS 186 and FIPS 140-2 Level 1 modules). Where the design is free, such as batch signatures on the audit log and signed receipts, Ed25519 is the default: small and deterministic, with no per-signature randomness to get wrong.
- Where keys live: service signing keys and TLS private keys sit in a hardware security module with dual control for key ceremonies, so an administrator who can log in still cannot export the key that makes receipts valid.
Prescription service and life cycle
The service is small and strict. Its core is a hierarchical state machine per
prescription. The German implementation shows the shape (source: gematik api-erp): POST /Task/$create reserves a
prescription ID and returns an access code; after signing, $activate moves draft to ready; a pharmacy's $accept
with the access code moves it to in-progress and returns a secret; $dispense and $close with the secret
complete it; $reject returns it to ready and deletes the secret; $abort deletes the task. A second pharmacy
calling $accept gets 409 Conflict. Nesting helps: expiry and cancellation by the prescriber are written once on
the parent state, not per substate.
Two contracts keep this safe under retries:
- Capability, not just identity: the access code is something the patient holds (a 2D code or an app) and shows
to the pharmacy of their choice; the service combines that capability with the caller's certificate and role in an
attribute-style check (Authorization models): "this role, this pharmacy, this task, this
state". The secret issued at accept then binds the later
dispenseandclosecalls to the pharmacy that accepted. - Retries replay, they do not repeat: a pharmacy whose response was lost must be able to retry. The German docs
allow the same pharmacy to fetch the accepted task again to recover a lost secret, and refuse other pharmacies. The
general technique is Idempotency keys: the client sends a key per logical operation; the service
stores the key with a request fingerprint and the first response and replays it, so "dispense" cannot happen twice.
The prescription ID reserved at
$createalready is such a key for creation; operations on it need their own.
The gematik docs also warn that $create consumes an ID from a finite number space even if never activated, so
clients must not pre-create blanks: an ID allocator with per-client quotas is part of the design.
Durability
A lost prescription is a patient without medicine; a duplicated one is a drug dispensed twice. Every transition is appended to a Write-ahead log and synced before it is acknowledged. The store is primary-backup: one primary orders all writes, a standby receives the log, and the acknowledgement rule decides what a crash can lose. Here the standby acknowledges before the call returns, because losing an acknowledged "dispensed" causes exactly the double fill the system exists to prevent. Quorum replication and Raft tolerate more failures but cost more machines and latency than a regional service needs. Point-in-time recovery covers what replication does not: a bad deployment or operator error that was faithfully replicated.
Delivery reliability
Pharmacies and prescribers are on uneven networks; calls fail. Clients retry with exponential backoff with jitter, so a recovering service sees spread-out retries, not a synchronized wave; safety of the retry comes from the idempotency keys above. A hub that forwards messages needs the same on its outbound side: store, forward, retry, and a dead-letter state that a human sees. US rules add a duty: if delivery of a controlled-substance prescription fails, any replacement must say the original was sent electronically and failed (source: eCFR 21 CFR 1311.102(c)).
Audit
Disputes happen ("I never signed that", "the quantity was changed"). Every state change, signature verification and access goes to a Tamper-evident audit log: an append-only log whose Merkle root is signed per batch, so an auditor can check inclusion and that history was not rewritten. The US rules ask for the same in plainer words: the pharmacy audit trail is analysed at least every calendar day, incidents are reported within one business day (source: eCFR 21 CFR 1311.215), and prescription content must not be altered in transit (1311.170). The log lives apart from the state store, so compromising the database does not let an attacker rewrite the evidence.
Operations
Run the service under Process supervision and Graceful draining: on
deploy a node stops accepting calls, finishes in-flight transitions, and clients retry elsewhere, which idempotency
keys make safe. hdr-histogram (no page yet) records latency percentiles for the pharmacy-counter path, where a slow
$accept is a customer standing at a desk. Deterministic simulation crashes
nodes between "accepted" and "dispensed" under a seeded clock and checks "at most one dispense per prescription".
Parsers for messages from many vendors run under coverage-guided fuzzing.
Data and control flow
One prescription from order to dispense (central-repository model, German shape):
- The prescriber drafts the order; the ECA rules run on the draft and the prescriber acknowledges warnings.
Drug choice comes from a value-set
$expand; the patient is selected by identifier or by$match. - The prescriber app authenticates to the service (certificate, token) and calls
$create; the service reserves an ID and returns the task with an access code. - The app builds the order bundle with the task ID inside, the physician signs it (card plus PIN, via the
connector), and the app calls
$activate. The service validates profile, signature and dates, adds its own signature, appends the transition to the write-ahead log, waits for the standby, and answers. - The patient receives the access code (app or printed 2D code) and presents it at a pharmacy.
- The pharmacy calls
$acceptwith the code; the service checks certificate, role, state and code, flips ready to in-progress, issues a secret, returns the signed bundle. A second pharmacy gets 409. - The pharmacy maps the coded drug to a product (
ConceptMap), dispenses, calls$dispenseand$closewith the secret; the service stores the dispense record and a receipt signed by the service, and the state becomes completed. Each step is audit-logged.
One message in a hub model (US shape, controlled substance): the prescriber completes two-factor authentication and the application signs and archives the order; it goes to the network over an authenticated channel, which validates structure and routes by pharmacy identifier; the pharmacy acknowledges (without an acknowledgement the sender retries with backoff, then reports failure); the pharmacy verifies the signature, dispenses and the fill status returns through the network.
Scaling
The workload is small next to a web service: a country issues prescriptions in numbers of the order of millions per day at most, each with a handful of calls. Levers in the order to pull them:
- Correctness under concurrency (two pharmacies accepting the same task) comes before throughput: one primary serialises transitions per prescription; shard by prescription ID only when its log write rate is the limit.
- Signature checks and TLS handshakes at peaks: cache validated certificate chains, keep connections open, add stateless API nodes in front of the primary.
- Terminology and search (typeahead,
$expand) are read-mostly and versioned by code-system release: precompute expansions, serve from replicas. - Record linkage is quadratic without blocking; with blocking it is a batch per directory update plus an online lookup.
- Audit log growth: seal and archive old segments, keep the signed roots online.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Double dispense | one prescription filled at two pharmacies | accept not exclusive, or a retried dispense replayed as new | one serialised state check; idempotency keys; 409 for the second caller |
| Lost secret | pharmacy cannot close an accepted prescription | response lost after accept | let the same pharmacy re-fetch, never another |
| Wrong patient | order attached to another person | match merged automatically | three-outcome thresholds, review band, identifier match first |
| Unmapped drug | pharmacy cannot find the product | no ConceptMap entry or retired code | keep original text in the order, versioned maps, alert on unmapped codes |
| Replicated mistake | bad data on primary and standby | deploy bug or operator error | point-in-time recovery to before the change |
| Clock skew | signature dates rejected, audit order wrong | node clocks drift | clock monitoring; US rules require the application clock within five minutes of NIST (eCFR 1311.120) |
| Retry storm | overload after a short outage | clients retry in lockstep | backoff with jitter, per-caller rate limits |
| Evidence rewritten | audit trail disputed | log in the same store, no external anchor | signed Merkle roots held elsewhere |
| ID exhaustion | $create fails or IDs run low | clients pre-create blanks | allocate on intent, per-client quotas |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| gematik api-erp | documentation and FHIR profiles of the German national e-prescription service | gematik/api-erp | Apache-2.0 | central-repository model; docs only, gematik operates the service |
| HAPI FHIR | Java API for FHIR clients and servers | hapifhir/hapi-fhir | Apache-2.0 | base for a prescriber or repository prototype |
| Medplum | healthcare platform for building compliant applications on FHIR | medplum/medplum | Apache-2.0 | FHIR-native app platform |
| Snowstorm | SNOMED CT terminology server on Elasticsearch | IHTSDO/snowstorm | see repo | the terminology service |
| Splink | probabilistic record linkage over SQL backends | moj-analytical-services/splink | MIT | patient index, duplicate cleanup |
| OpenEMR | open-source EHR and practice management | openemr/openemr | GPL-3.0 | a prescriber-side application |
The hub alternative. In the US the operator forwards messages and keeps a directory of prescribers and pharmacies (Surescripts, proprietary). The parts are the same, but the operator does not own the record, so the state machine lives in sender and receiver and the network adds routing, directory and delivery acknowledgement. Pick a central repository when one authority exists and patients choose the pharmacy at the counter; pick a hub when many independent record owners already exist.
Sources
- HL7 FHIR R5: MedicationRequest, MedicationDispense, Search, REST API, Terminology module, Code systems, $expand, $subsumes, $translate, Patient $match.
- gematik, E-Rezept API-Dokumentation: api-erp, files
docs/erp_bereitstellen.adoc,docs/erp_abrufen.adoc,docs/authentisieren.adoc. - 21 CFR Part 1311 (electronic prescriptions for controlled substances), sections 1311.102, .115, .120, .140, .145, .170, .205, .215: eCFR.
- RxNorm (NLM); NCPDP, the US pharmacy standards body.
- I. P. Fellegi and A. B. Sunter, A Theory of Record Linkage, JASA 64 (1969), doi 10.1080/01621459.1969.10501049.
- RFCs: 8446 (TLS 1.3), 5280 (X.509), 8032 (EdDSA).