Most introductions to prescribing on FHIR show a MedicationRequest in JSON and stop there. The resource is the easy part. A prescription is a long-lived, shared object. A prescriber writes it, one or more pharmacies act on it over weeks or months, and none of them is in the room with the others. The design questions are about that lifetime: who may do what, in which state, and what the record says when two of them act at the same moment.
This article models that lifetime on FHIR R4 with three resources, expresses the rules as a single function in C#, and maps every transition to one FHIR transaction. The same scenarios are then run against a FHIR server, including the ones where a pharmacy and a prescriber collide. The regulatory and architectural setting for electronic prescribing in Europe is covered in an earlier article; this one stays with the mechanics.
Two hazards drive the design. The first is a supply made twice against one authorisation. The second is a supply made against a prescription the prescriber has withdrawn, perhaps because of an interaction or an error in the dose. Checks in a user interface reduce both. The aim here is stronger: that the shared record cannot be brought into either state, whatever the client systems do.
Three resources, one prescription
| Resource | What it holds | Who changes it |
|---|---|---|
MedicationRequest | The order: medicine, patient, prescriber, dosage, and under dispenseRequest the quantity for one supply, the number of repeats and the validity period | The prescriber’s side. Its status changes only when the prescription as a whole is finished, withdrawn or expired |
Task | Fulfilment: whether a pharmacy is working on the prescription and which one. focus points at the MedicationRequest and owner at the pharmacy | Pharmacies, on every claim, release and supply |
MedicationDispense | One supply handed to the patient, with authorizingPrescription pointing back at the order | Created once by the dispensing pharmacy. Never updated in this model |
The Task is what makes this workable. Without it, a pharmacy that wants to say “we are dealing with this one” has to edit the prescriber’s order, and the order’s status ends up carrying two meanings. FHIR’s own description of Task is a resource that tracks the state of completion of an activity, with the details of the activity in the resource it focuses on. That is exactly the split needed.
One detail of dispenseRequest is a regular source of off-by-one errors. numberOfRepeatsAllowed does not include the first supply. The specification’s own example is thirty tablets with three repeats, which can be dispensed four times. The sample turns the field into “supplies allowed” at the boundary, as repeats plus one, and never uses the raw number again.
Five states, and what FHIR calls them
The lifecycle has five states. None is a FHIR status. Each is a combination of the two status fields, which is why it pays to name them once and map in a single place.

| State | MedicationRequest.status | Task.status | Meaning |
|---|---|---|---|
| Open | active | requested | Any pharmacy may claim it |
| Claimed | active | accepted, with an owner | One pharmacy is dispensing |
| Completed | completed | completed | Every authorised supply has been made |
| Cancelled | cancelled, or stopped | cancelled | Withdrawn by the prescriber |
| Expired | stopped | cancelled | The validity period ended with supplies outstanding |
The Cancelled row has two values for a reason. FHIR defines cancelled as a prescription withdrawn before any administration has occurred, and stopped as one permanently halted before all of them have. A prescription withdrawn after its first supply is therefore stopped, as is one that expires, and statusReason says which. Writing cancelled on a prescription with a supply on record would tell a downstream system that the patient never received anything.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
P["Prescriber signs"] --> O["Open
MedicationRequest active
Task requested"]
O -- "claim" --> C["Claimed
Task accepted
owner is one pharmacy"]
C -- "release" --> O
C -- "dispense, supplies remain" --> O
C -- "dispense, last supply" --> D["Completed
MedicationRequest completed"]
O -- "cancel" --> X["Cancelled
cancelled, or stopped
if a supply was made"]
C -- "cancel" --> X
O -- "expire" --> E["Expired
MedicationRequest stopped"]
C -- "expire" --> E
classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef alert fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
classDef db fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
class P db
class O,C compute
class D store
class X,E alertThe rules as one function
The rules take the current prescription, a command and the time, and return either a refusal with a reason or the next prescription. There is no I/O in them and no FHIR, so they can be tested exhaustively, and the same function can run in a prescribing system, a pharmacy system and the service between them.
namespace Prescribing.Core;
/// <summary>Where a prescription is in its life. Each value corresponds to one combination of FHIR statuses.</summary>
public enum PrescriptionState
{
/// <summary>MedicationRequest active, Task requested: any pharmacy may claim it.</summary>
Open,
/// <summary>MedicationRequest active, Task accepted with an owner: one pharmacy is dispensing.</summary>
Claimed,
/// <summary>MedicationRequest completed, Task completed: every authorised supply has been made.</summary>
Completed,
/// <summary>MedicationRequest cancelled, or stopped if a supply was already made; Task cancelled: withdrawn by the prescriber.</summary>
Cancelled,
/// <summary>MedicationRequest stopped, Task cancelled: the validity period ended with supplies outstanding.</summary>
Expired,
}
public enum Refusal
{
None,
AlreadyClaimed,
NotClaimed,
ClaimedByAnotherPharmacy,
NoSuppliesRemaining,
OutsideValidityPeriod,
NotYetExpired,
Closed,
}
/// <summary>Everything the rules need to know. Nothing clinical, and nothing that identifies the patient.</summary>
public sealed record Prescription(
PrescriptionState State, string? ClaimedBy, int SuppliesAllowed, int SuppliesMade, DateTimeOffset ValidFrom, DateTimeOffset ValidUntil)
{
public int SuppliesRemaining => SuppliesAllowed - SuppliesMade;
}
public abstract record Command
{
public sealed record Claim(string Pharmacy) : Command;
public sealed record Release(string Pharmacy) : Command;
/// <summary>One supply handed to the patient. The dispense id makes a repeated submission harmless.</summary>
public sealed record Dispense(string Pharmacy, string DispenseId) : Command;
public sealed record Cancel(string Prescriber, string Reason) : Command;
public sealed record Expire : Command;
}
public sealed record Decision(Refusal Refusal, Prescription Next)
{
public bool Accepted => Refusal == Refusal.None;
}
/// <summary>
/// The rules, as one pure function: the current prescription, a command and the time go in; either a refusal
/// or the next prescription comes out. No I/O and no FHIR, so every state and command pair can be tested, and
/// the same function decides in the prescriber's system, the pharmacy's and the service between them.
/// </summary>
public static class PrescriptionLifecycle
{
public static Decision Decide(Prescription current, Command command, DateTimeOffset now)
{
Decision Refuse(Refusal why) => new(why, current);
Decision Accept(Prescription next) => new(Refusal.None, next);
bool inValidity = now >= current.ValidFrom && now <= current.ValidUntil;
if (current.State is PrescriptionState.Completed or PrescriptionState.Cancelled or PrescriptionState.Expired)
{
return Refuse(Refusal.Closed);
}
switch (command)
{
case Command.Claim claim:
if (current.State == PrescriptionState.Claimed) { return Refuse(Refusal.AlreadyClaimed); }
if (!inValidity) { return Refuse(Refusal.OutsideValidityPeriod); }
if (current.SuppliesRemaining <= 0) { return Refuse(Refusal.NoSuppliesRemaining); }
return Accept(current with { State = PrescriptionState.Claimed, ClaimedBy = claim.Pharmacy });
case Command.Release release:
if (current.State != PrescriptionState.Claimed) { return Refuse(Refusal.NotClaimed); }
if (current.ClaimedBy != release.Pharmacy) { return Refuse(Refusal.ClaimedByAnotherPharmacy); }
return Accept(current with { State = PrescriptionState.Open, ClaimedBy = null });
case Command.Dispense dispense:
if (current.State != PrescriptionState.Claimed) { return Refuse(Refusal.NotClaimed); }
if (current.ClaimedBy != dispense.Pharmacy) { return Refuse(Refusal.ClaimedByAnotherPharmacy); }
if (!inValidity) { return Refuse(Refusal.OutsideValidityPeriod); }
if (current.SuppliesRemaining <= 0) { return Refuse(Refusal.NoSuppliesRemaining); }
int made = current.SuppliesMade + 1;
return Accept(current with
{
SuppliesMade = made,
ClaimedBy = null,
State = made == current.SuppliesAllowed ? PrescriptionState.Completed : PrescriptionState.Open,
});
case Command.Cancel:
// A prescriber may withdraw a prescription at any point before it is closed, claimed or not.
// A pharmacy that was about to dispense finds out when its own write is refused.
return Accept(current with { State = PrescriptionState.Cancelled, ClaimedBy = null });
case Command.Expire:
if (now <= current.ValidUntil) { return Refuse(Refusal.NotYetExpired); }
return Accept(current with { State = PrescriptionState.Expired, ClaimedBy = null });
default:
throw new ArgumentOutOfRangeException(nameof(command));
}
}
}
Five states and seven commands make thirty-five pairs, and the test checks each of them. The table shows the two open states; the three closed states refuse everything.
| Claim by A | Claim by B | Release by A | Dispense by A | Dispense by B | Cancel | Expire | |
|---|---|---|---|---|---|---|---|
| Open | Claimed | Claimed | Not claimed | Not claimed | Not claimed | Cancelled | Not yet expired |
| Claimed by A | Already claimed | Already claimed | Open | Open, or Completed on the last supply | Claimed by another pharmacy | Cancelled | Not yet expired |
Three decisions in the function are policy and would be different in another jurisdiction, which is the argument for keeping them in one readable place. A dispense clears the claim, so the next supply can come from any pharmacy. A claim made in time does not extend the validity period: dispensing after it ends is refused even if the claim was earlier. And a prescriber can cancel a claimed prescription without the pharmacy’s agreement. That last choice only works if the pharmacy cannot then complete the dispense, which is the job of the next section.
A property test runs two thousand random command sequences and checks after every step that supplies made never exceed supplies allowed, and that the prescription is completed exactly when the two are equal.
Every transition is one transaction
The store loads the MedicationRequest, its Task and a count of completed dispenses, asks the rules for a decision, and writes the result as a single FHIR transaction Bundle.
/// <summary>
/// Decides the command against what was loaded and, if it is allowed, writes the result. A Conflict means
/// somebody else changed the prescription first: load again and decide again, never retry blindly.
/// </summary>
public async Task<ApplyResult> ApplyAsync(LoadedPrescription loaded, Command command, CancellationToken cancellationToken = default)
{
if (command is Command.Dispense repeat && await DispenseExistsAsync(repeat.DispenseId, cancellationToken))
{
return new ApplyResult(ApplyOutcome.AlreadyApplied);
}
DateTimeOffset now = clock.GetUtcNow();
Decision decision = PrescriptionLifecycle.Decide(loaded.Prescription, command, now);
if (!decision.Accepted)
{
return new ApplyResult(ApplyOutcome.Refused, decision.Refusal);
}
JsonArray entries = [];
JsonObject task = loaded.Task.DeepClone().AsObject();
task["status"] = decision.Next.State switch
{
PrescriptionState.Open => "requested",
PrescriptionState.Claimed => "accepted",
PrescriptionState.Completed => "completed",
_ => "cancelled",
};
task["lastModified"] = Instant(now);
task.Remove("owner");
if (decision.Next.ClaimedBy is { } pharmacy)
{
task["owner"] = new JsonObject { ["reference"] = pharmacy };
}
entries.Add(Update(task, $"Task/{loaded.TaskId}", loaded.TaskVersion));
// FHIR reserves "cancelled" for a prescription withdrawn before anything was supplied. Once a supply
// has been made, a withdrawal is "stopped", as is expiry; the status reason says which.
string requestStatus = decision.Next.State switch
{
PrescriptionState.Completed => "completed",
PrescriptionState.Cancelled when loaded.Prescription.SuppliesMade == 0 => "cancelled",
PrescriptionState.Cancelled or PrescriptionState.Expired => "stopped",
_ => "active",
};
if (requestStatus != loaded.Request["status"]!.GetValue<string>())
{
JsonObject request = loaded.Request.DeepClone().AsObject();
request["status"] = requestStatus;
if (command is Command.Cancel cancel)
{
request["statusReason"] = Closure("withdrawn", cancel.Reason);
}
else if (command is Command.Expire)
{
request["statusReason"] = Closure("expired", "Validity period ended");
}
entries.Add(Update(request, $"MedicationRequest/{loaded.RequestId}", loaded.RequestVersion));
}
if (command is Command.Dispense dispense)
{
entries.Add(new JsonObject
{
["fullUrl"] = $"urn:uuid:{Guid.NewGuid()}",
["resource"] = new JsonObject
{
["resourceType"] = "MedicationDispense",
["identifier"] = new JsonArray(new JsonObject { ["system"] = DispenseIdSystem, ["value"] = dispense.DispenseId }),
["status"] = "completed",
["medicationCodeableConcept"] = loaded.Request["medicationCodeableConcept"]!.DeepClone(),
["subject"] = loaded.Request["subject"]!.DeepClone(),
["performer"] = new JsonArray(new JsonObject { ["actor"] = new JsonObject { ["reference"] = dispense.Pharmacy } }),
["authorizingPrescription"] = new JsonArray(new JsonObject { ["reference"] = $"MedicationRequest/{loaded.RequestId}" }),
["quantity"] = loaded.Request["dispenseRequest"]!["quantity"]!.DeepClone(),
["whenHandedOver"] = Instant(now),
},
["request"] = new JsonObject
{
["method"] = "POST",
["url"] = "MedicationDispense",
["ifNoneExist"] = $"identifier={Uri.EscapeDataString($"{DispenseIdSystem}|{dispense.DispenseId}")}",
},
});
}
JsonObject bundle = new() { ["resourceType"] = "Bundle", ["type"] = "transaction", ["entry"] = entries };
using StringContent content = new(bundle.ToJsonString(), Encoding.UTF8);
content.Headers.ContentType = FhirJson;
using HttpResponseMessage response = await http.PostAsync(string.Empty, content, cancellationToken);
return response.StatusCode switch
{
HttpStatusCode.OK => new ApplyResult(ApplyOutcome.Applied),
HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed => new ApplyResult(ApplyOutcome.Conflict),
_ => throw new HttpRequestException($"The FHIR server refused the transaction with status {(int)response.StatusCode}.", null, response.StatusCode),
};
}
Three mechanisms in that method do the safety work.
- Every update carries
If-Match. The Task is updated on every transition, so its version is the concurrency token for the whole prescription. Two actors who read the same version cannot both write. - The Bundle is a transaction. A supply that completes the prescription changes three resources: a new MedicationDispense, the Task and the MedicationRequest. Either all three are written or none is. There is no instant at which a dispense exists against a prescription that another write has just cancelled.
- The dispense is a conditional create. The pharmacy system assigns each supply an identifier before it sends anything. A repeat of the same request, after a timeout for instance, is recognised and creates nothing.
A conflict is not an error to retry. It means the facts changed, so the caller loads the prescription again and puts the question to the rules again. Very often the answer is now different.
When two parties act at once
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E'}}}%%
sequenceDiagram
participant D as Prescriber system
participant F as FHIR server
participant P as Pharmacy system
P->>F: Read prescription, Task version 2
D->>F: Read prescription, Task version 2
Note over P: Decides to dispense
D->>F: Transaction, cancel, If-Match version 2
F-->>D: 200 OK, Task now version 3
P->>F: Transaction, dispense, If-Match version 2
F-->>P: 409 Conflict, nothing written
P->>F: Read prescription again
F-->>P: Cancelled
Note over P: Does not hand over the medicine
| Scenario | Result on the FHIR server |
|---|---|
| Two pharmacies read an open prescription and both claim it | One transaction applied, the other a conflict. The loser reloaded and was refused as already claimed |
| Pharmacy B tries to dispense what pharmacy A has claimed | Refused by the rules before any write |
| Prescriber cancels after the pharmacy has read the prescription and decided to dispense | Cancel applied. The dispense was a conflict, and on reload was refused as closed. No MedicationDispense exists |
| Cancel and dispense sent at the same instant, twenty prescriptions | One of the two applied each time. Every prescription ended cancelled with no dispense, or completed with exactly one |
| The same dispense sent a second time | Recognised by its identifier. One MedicationDispense |
| A third claim on a completed prescription | Refused as closed |
In the simultaneous case the winner varies, and it should. In the linked run the cancellation arrived first nineteen times and the dispense once, and other runs have split differently. Either order is a legitimate history. What must never appear is the mixture, a cancelled prescription with a supply recorded after the cancellation, and in twenty attempts it did not.
When the dispense wins, the cancellation fails with a conflict and the prescriber’s system finds the prescription completed. That is a clinical event and not a technical one: the medicine has been handed over, and the prescriber needs to be told so that the patient can be contacted. A design review should ask what the prescribing system does with that answer.
A version-aware update that lost its race came back from this server as 409 Conflict. The FHIR specification gives 412 Precondition Failed for a version mismatch, so the store treats both as the same thing.
Repeats, release and expiry
| Scenario | Result on the FHIR server |
|---|---|
| Two supplies allowed: A claims and dispenses, B claims and dispenses | Open with one supply made after the first, completed after the second |
| The winner of a claim releases it | The other pharmacy then claims it successfully |
| Prescriber withdraws after the first of two supplies | Closed as cancelled, stored as stopped with its reason. The first supply stays on record |
| A claimed prescription passes the end of its validity period | Dispensing refused as outside the validity period |
| The housekeeping job runs before the period has ended | Refused as not yet expired |
| The housekeeping job runs after it | MedicationRequest stopped, Task cancelled, reads back as expired |
Expiry deserves a comment. FHIR has no status that becomes true by the passage of time, so until something writes to the record, an out-of-date prescription still says active. The rules therefore refuse to dispense on the validity period itself, and a scheduled job later brings the stored status into line. A reader that trusts status alone, without looking at validityPeriod, will be wrong in the interval between the two.
A second opinion on the server state
The C# tests assert through the C# store, which could hide a mistake in its own mapping. A separate Python script therefore reads what was left on the server with plain searches and no shared code. It confirms the statuses, that the two supplies were dispensed by pharmacy A and then pharmacy B, that all twenty raced prescriptions are coherent, and it repeats the claim race with two raw version-aware updates.
It also reads the Task history, which gives the fulfilment trail of the two-supply prescription without any audit code having been written:
requested -> accepted -> requested -> accepted -> completed
That is the server’s own version history, and a useful by-product of keeping fulfilment in a separate resource. It complements a proper audit trail and does not replace one, since it records what changed and not who asked.
What a production service adds
The sample isolates the lifecycle. A service that pharmacies and prescribers depend on needs several things around it, and a design review should find each of them.
| Concern | What is missing from the sample | Where it belongs |
|---|---|---|
| Identity | The pharmacy and prescriber are parameters. Nothing proves who is calling | The authorisation layer: the caller’s identity from its token decides which organisation it may act as |
| Authorisation | Any caller could cancel | Only the prescriber, or a defined delegate, may cancel. Only the owner of the Task may dispense |
| Audit and provenance | Task history only | An AuditEvent for every read and write, and Provenance or a signature on the order |
| Profiles and terminology | Plain R4 and a made-up medication code | The national or regional implementation guide, its identifiers and its medicines terminology |
| Partial supply and substitution | One supply is all or nothing | Quantity tracking within a supply, and MedicationRequest.substitution |
| Controlled medicines | No special handling | Shorter validity, stricter repeat rules, and usually additional record keeping |
| Notification | The prescriber learns of a lost race only on its own request | A subscription or message when a prescription is dispensed, cancelled or expires |
What it costs to run
The model is cheap in FHIR terms, and the arithmetic is worth doing before sizing a server. Signing is one transaction. Each supply is two: a claim and a dispense. A prescription with two supplies is therefore five write transactions over its life, plus one for a cancellation or an expiry where that applies. The Task gains two versions for each supply, so its history is as long as the fulfilment trail and no longer. Reads dominate: a pharmacy system loads the order, the Task and a count of dispenses before every decision. The count is the one to watch, because it is a search, and it is the first thing to replace with a stored counter on the Task if profiling shows it matters.
Decision framework
| Approach | How concurrency is controlled | Fits when |
|---|---|---|
| A Task for each prescription, with version-aware updates, as here | Optimistic: the server refuses a write based on a stale read | A shared record that several parties read and update through a FHIR API |
| Status on the MedicationRequest only | The same mechanism, but on the order itself | A closed system with one dispenser, where nobody else needs to see work in progress |
| Exclusive download: the first pharmacy to fetch a prescription takes it out of circulation | The fetch is the lock | Token-based national services where a prescription is released to exactly one pharmacy |
| Messaging between systems, with no shared record | Each side keeps its own state and reconciles | Point-to-point links, at the price of every disagreement becoming a reconciliation problem |
The exclusive download model is used by some national services and is simpler for pharmacies. The Task model shown here is the natural fit where the prescription has to stay visible to the prescriber and to more than one potential dispenser while it is being fulfilled.
Key takeaways
- Model the prescription as an order, a Task that tracks fulfilment, and a dispense for each supply. The order then keeps one meaning.
- Name the lifecycle states yourself and map them to FHIR statuses in one place.
cancelledandstoppedare not interchangeable. - Put the rules in one pure function and test every state against every command.
- Write every transition as one transaction with
If-Matchon its updates. A conflict means reload and decide again. - The two hazards, a double supply and a supply after withdrawal, should be impossible to record, not merely unlikely.
If you work on a prescribing service and have settled the claim and cancellation rules differently, I would be glad to hear how in the comments.
The rules, their 35 state and command pairs and the property test run on .NET 10 with no server. The 23 server checks run the scenarios through the C# store against HAPI FHIR 8.0.0 in a container, and 10 further checks read the resulting state from Python 3.13 with plain HTTP. All of it runs in GitHub Actions on synthetic data. HAPI is one server, and transaction and If-Match behaviour should be confirmed on whichever server a project uses. Authentication, authorisation, signatures, audit events and national profiles are outside the sample. The workflow run has the logs.
The complete sample is on GitHub: blog-samples-working-repo/eprescribing-fhir-lifecycle. It contains the lifecycle rules, the FHIR store, the self-test with its server scenarios, and the Python script that checks the server state independently.
Glossary
- MedicationRequest— The FHIR resource for an order or prescription of one medicine for one patient.
- MedicationDispense— The FHIR resource recording that a medicine was supplied to a patient.
- Task— A FHIR resource that tracks the state of an activity, here the fulfilment of a prescription.
- Claim— A pharmacy taking responsibility for the next supply, so that no other pharmacy acts on it.
- Validity period— The interval in which a prescription may be dispensed, held in dispenseRequest.validityPeriod.
- Transaction Bundle— A set of FHIR interactions that the server applies completely or not at all.
- If-Match— The header, or Bundle field, that makes an update conditional on the resource version last read.
- Conditional create— A create that does nothing if a resource matching the given search already exists.
References & Further Reading
- → MedicationRequest, FHIR R4— The dispenseRequest element, including how repeats are counted.
- → MedicationRequest status codes, FHIR R4— The definitions that separate cancelled from stopped.
- → Task, FHIR R4— Tracking the completion of an activity, with focus and owner.
- → RESTful API, FHIR R4— Transactions, version-aware updates and conditional create.
- → ePrescribing in the EU and Ireland— The regulatory and architectural context, on this site.
- → Sample code for this article— Rules, FHIR store, tests and the independent verifier, with the CI workflow that runs them.
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.