FHIR Microservices Architecture: Splitting a Monolithic Server Without Breaking Search

Diagram: FHIR Microservices Architecture: Splitting a Monolithic Server Without Breaking Search. Diagram illustrating the article's core structure and decision points.

Splitting a monolithic FHIR server into microservices is one of those architecture decisions that looks obvious on the whiteboard and painful in production. The FHIR REST API's cross-resource search semantics — _include, _revinclude, chained parameters — assume the server sees all resources, which breaks the moment you shard by resource type across microservices.

The three patterns teams settle on:

Pattern A: Resource-typed microservices with a search facade. Split into PatientService, ObservationService, EncounterService, etc., each backed by its own store. Put a search facade in front that decomposes a GET Patient?_revinclude=Observation:subject request into two calls (Patient lookup + Observation query) and stitches results into a Bundle. The FHIR search parameter specification is the authority on what needs to work; test against _include, _revinclude, chained (Patient?general-practitioner.name=Smith), and _has semantics. Most facade implementations skip one of these and only find out in QA.

Pattern B: Vertical slice per bounded context. Instead of per-resource-type splits, group by clinical domain: MedicationService (MedicationRequest + MedicationDispense + MedicationAdministration + Medication), EncounterService (Encounter + Condition + Procedure), etc. This aligns with how SMART on FHIR apps consume data (per-scope) and avoids the cross-service _include problem for common queries. The tradeoff: cross-domain queries (Patient's Observations + MedicationRequests together) still need a facade.

Pattern C: CQRS with a projected read model. Keep write-side per bounded context, project into a read-side that's a single denormalized store. Reads are cheap and support arbitrary search; writes propagate through an event stream. Best fit if your read/write ratio is 100:1+, or you have complex CQL execution that needs the whole graph. Not worth the operational cost otherwise.

Common breakages when microservicing

1. Transaction Bundles that cross services. FHIR Bundle type transaction requires atomic all-or-nothing semantics. Two services can't easily provide this without distributed 2PC or Saga patterns. Most teams downgrade transaction bundles to type batch (independent operations, no atomicity) and document the change to consumers.

2. Subscription notifications. FHIR Subscriptions (R4B + R5 topic-based model) require the server to detect events across all resources. Distribute this by having each microservice publish domain events, then a Subscription service consumes them and dispatches to subscribers.

3. _lastUpdated cursor semantics. A caller doing incremental sync via _lastUpdated=gt2026-01-01 expects a consistent cursor. With independent stores, each service has its own clock. The pragmatic fix: use a shared distributed sequence (Kafka offset, Postgres logical replication slot) as the _lastUpdated source rather than each service's local timestamp.

Reference architectures worth studying

The Medplum bots and workflows documentation describes a facade-plus-event pattern that works at moderate scale. Aidbox's app SDK supports building sidecar services that share the same underlying store — a middle ground between monolith and full microservices. And HAPI FHIR's client is the reference implementation for what a facade needs to do to look like a compliant FHIR endpoint.

Microservices for FHIR aren't a free lunch, but they're the right answer if your monolith is hitting write-throughput limits, or if different resource types have wildly different query patterns. Pick the pattern based on your bounded contexts, not on the resource-type split — that's the mistake that gets teams stuck a year in.