-
Notifications
You must be signed in to change notification settings - Fork 1
FHIR Boundary
This page explains why openrunic stores data relationally and speaks FHIR only at the API edge, which resources are mapped today, and what it takes to add one. It is for anyone touching packages/fhir or apps/api/src/fhir/.
The decision is recorded in ADR-0002. Read it before changing anything here.
FHIR compliance is a property of the API surface. Certification programmes and integrations test what a server returns over HTTP, not how bytes are laid out on disk. Nothing in those programmes requires FHIR-shaped storage.
Meanwhile the application needs fast, strongly typed, relational access. Schedules join practitioners to slots to appointments. Audit queries filter by actor and time range. Screens want narrow projections, not multi-kilobyte resource documents.
So the two needs get the shape each is best served by:
flowchart LR
subgraph Storage["packages/database"]
A["Normalised Postgres tables<br/>designed for access patterns"]
end
subgraph Mapping["packages/fhir"]
B["toFhirX / fromFhirX<br/>round-trip tested"]
end
subgraph Edge["apps/api/src/fhir"]
C["FHIR R4 over HTTP<br/>application/fhir+json"]
end
A <--> B
B <--> C
C <--> D["SMART apps, integrations,<br/>certification test kits"]
The cost is real and was accepted knowingly. Every new resource or field requires domain schema, mapper code, and round-trip tests. Generic FHIR search has to be implemented parameter by parameter against relational columns rather than inherited from a FHIR-native store. The project starts with a deliberately small search surface.
R4 (4.0.1), exclusively. FHIR_VERSION is exported from packages/fhir/src/index.ts and matches CapabilityStatement.fhirVersion.
The reasoning is in docs/compliance.md: the US and EU implementation guides that carry regulatory weight are all R4-based. R4B and R5 have no regulatory driver. The next jump is R6, expected around 2027. Because resource handling sits behind the mapping layer, a future migration is a mapper change rather than a rewrite.
packages/fhir/src/ has one module per resource. 23 mapper pairs are exported today:
| Area | Resources |
|---|---|
| Directory |
Patient, Practitioner, PractitionerRole, Organization, Location
|
| Coverage | Coverage |
| Scheduling |
Appointment, Encounter
|
| Clinical |
Condition, MedicationRequest, MedicationStatement, AllergyIntolerance, Immunization, Observation
|
| Results |
DiagnosticReport, ServiceRequest, Specimen, and a result-flavoured Observation
|
| Documents and work |
DocumentReference, Task
|
| Financial | Claim |
| Governance |
Consent, Provenance
|
Shared building blocks live alongside them: Bundle assembly, OperationOutcome construction, reference helpers, the SYSTEMS map of canonical code-system URIs, primitive helpers (codeableConcept, humanName, address, period, quantity, money, and the compaction helpers), and the search-parameter registry.
Each mapper module also exports a *_DROPPED_FIELDS constant, for example PATIENT_DROPPED_FIELDS. That is the explicit record of what the mapper does not carry across. Dropping a field is allowed; dropping it silently is not.
The mapping layer is well ahead of the routes. Today apps/api serves exactly one FHIR resource:
| Route | Interaction |
|---|---|
GET /fhir/metadata |
CapabilityStatement, public |
GET /fhir/Patient |
search-type, returning a searchset Bundle |
GET /fhir/Patient/{id} |
read |
POST /fhir/Patient |
create |
ALL /fhir/* |
404 as an OperationOutcome
|
There is no update or delete interaction, and no other resource type is routed. Every other mapper exists and is tested but is not yet reachable over HTTP.
This is the mechanism that keeps the mapping layer honest, and it is the one rule in this area that is not negotiable: a mapper without round-trip tests does not merge.
The harness lives in packages/fhir/src/test-support/round-trip.ts. Every mapper pair runs the same three assertions against every fixture.
-
domain -> FHIR -> domainis deep-equal to the input. Anything the mapper quietly drops shows up here immediately. -
FHIR -> domain -> FHIRis deep-equal to the intermediate resource. That is the write path: a resource posted to the API and read back is stable. - The emitted JSON is FHIR-shaped.
resourceTypeis set, and nothing anywhere in the tree isnull,undefined, an empty string, or an empty array.
Fixtures always include a sparse case and a degenerate all-empty case, because the interesting failures are in the absent fields rather than the full ones.
Assertion three matters more than it looks. FHIR consumers reject null and empty arrays, and a mapper that emits "name": [] or "birthDate": "" produces a resource that validates locally and fails at an integration partner. The compact and compactOrUndefined helpers in primitives.ts exist so mappers can build objects freely and strip the empties in one place.
packages/fhir/src/search-params.ts is the registry. It defines SUPPORTED_RESOURCE_TYPES, COMMON_SEARCH_PARAMS, and a per-resource SEARCH_SUPPORT table describing which parameters exist, their type, their modifiers, and which are must-support.
The same registry does three jobs, which is what stops them drifting apart:
- It generates the CapabilityStatement, so the statement can never advertise a parameter the server does not implement.
- It backs
rejectUnsupportedParams, so an unknown parameter is a 400 with issue codenot-supportedrather than being silently ignored. - It documents the surface for contributors.
Patient search supports _id, identifier, family, given, name, birthdate, gender, and active, plus the common _count and _offset. identifier accepts either a bare value or the system|value form, with the MRN system as the default.
Chaining, _include, and _revinclude are not supported, and are rejected explicitly rather than ignored. A server that quietly drops _include returns a technically valid but wrong answer, which is worse than an error.
FHIR routes return OperationOutcome with content type application/fhir+json. The mapping from the internal error kind to the FHIR issue code is fixed and listed on API design. Every outcome carries one extra informational issue containing the request id.
The operationOutcome() builder in @openrunic/fhir runs the same compaction as the mappers, so an outcome never contains an empty array either.
FHIR search uses _count and _offset rather than the internal page and pageSize. _offset must be a multiple of _count, or the request is rejected. Bundles carry total for the whole result set plus self, next, and previous links with their parameters sorted for determinism. An empty bundle omits entry entirely.
-
Model the domain first. Add or extend the tables in
packages/database. Do not shape storage to match the resource. -
Write the mapper pair in
packages/fhir/src/<resource>.ts, exportingtoFhirX,fromFhirX, theDomainXtype, andX_DROPPED_FIELDS. Build codes through theSYSTEMSmap rather than typing URIs by hand. - Write round-trip tests with at least a full fixture, a sparse fixture, and an all-empty fixture. The suite will not accept a mapper without them.
-
Export from the barrel in
packages/fhir/src/index.ts, keeping the file's existing grouping. -
Register search support in
search-params.tsfor every parameter you intend to serve, and only those. -
Add the resource to
FHIR_RESOURCESinapps/api/src/fhir/registry.tswith its interactions and profile. The CapabilityStatement and the parameter rejection both follow from this automatically. - Write route tests covering read, search, the cross-tenant 404, and at least one rejected search parameter.
openrunic is an open-source operating system for human health. Pre-alpha: do not run it in production, and never put real patient data into it.
Repository · Licence (AGPL-3.0-only) · Security policy · Contributing · Code of conduct
Where this wiki and the repository disagree, the repository is right.