Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Terminology servers

openEHR records carry coded values — a diagnosis, a route of administration, a laboratory unit. Some codes come from openEHR’s own terminology; others must be validated against an external code system such as SNOMED CT or LOINC. FerroEHR serves the bundled openEHR terminology in-process and can additionally validate and expand coded values against any external FHIR R4 terminology server.

The bundled openEHR terminology

The server ships the openEHR terminology bundle (Terminology 3.1.0) and uses it by default, with no external dependency. It answers the questions the platform needs during validation and querying: which terminologies exist, whether a code belongs to one, what a term’s rubric is, whether one code subsumes another, and whether a code is a member of a value set.

You can also expose these lookups over a small read-only REST surface. It is an extension (not part of the openEHR ITS-REST contract) and is off by default; when disabled, every route returns 404 as if unmounted. Enable it with FERROEHR__TERMINOLOGY__API_ENABLED=true, and it serves:

MethodPathPurpose
GET/terminologylist terminologies
GET/terminology/{terminology_id}describe one terminology
GET/terminology/{terminology_id}/term/{code}look up a term
GET/terminology/{terminology_id}/subsumes?ref_code=&candidate=subsumption test
GET/terminology/{terminology_id}/value_set/{value_set_id}get a value set
GET/terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date=validate a code

(All paths are relative to the API base path, /ferroehr/rest/openehr/v1.)

External FHIR terminology servers

A template can bind a coded element to an external value set via a terminology://… reference — for example a FHIR value-set expand or validate-code operation against a named value set. When external terminology is enabled, the composition validator routes each such coded element to the configured FHIR R4 terminology server: it resolves the coded value’s system and code and asks the server whether the code is a member of the value set. If it is not, the composition is rejected along with any other validation errors.

The server prefers a direct code-validation check and falls back to expanding the value set and testing membership where a server lacks direct validation. Only the external bindings go to the FHIR server — openEHR and local terminologies are still served by the in-process bundle.

Note

The CDR is only ever a client of the terminology server. FerroEHR does not implement a terminology server; you run an off-the-shelf FHIR R4 server and point the CDR at it by URL. HAPI FHIR is a good open, single-container default for development and CI; Snowstorm is the opt-in choice for genuine SNOMED CT subsumption (heavier — it needs Elasticsearch and a SNOMED CT licence).

Enabling and configuring it

External terminology is off by default; validation then uses only the in-process bundle. These keys live in the [terminology.external] section of ferroehr.toml (providers are a map, so complex blocks are usually supplied in that TOML file); each can be overridden with the shown FERROEHR__TERMINOLOGY__EXTERNAL__* environment variable, with __ separating nested keys:

KeyMeaning
FERROEHR__TERMINOLOGY__EXTERNAL__ENABLEDmaster switch (default false)
FERROEHR__TERMINOLOGY__EXTERNAL__FAIL_ON_ERRORon a server error: true rejects (fail-closed), false accepts (fail-open)
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__<NAME>__TYPEprovider type — fhir (R4)
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__<NAME>__URLthe FHIR base URL, e.g. http://terminology:8090/fhir

A provider can carry per-provider OAuth2 client-credentials and mutual-TLS settings for servers that require them. A short worked example, pointing the CDR at a HAPI FHIR container over Docker Compose:

services:
  ferroehr:
    environment:
      FERROEHR__TERMINOLOGY__EXTERNAL__ENABLED: "true"
      FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__TYPE: "fhir"
      FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__URL: "http://terminology:8090/fhir"

Tip

A FHIR terminology server starts empty. Seed the value sets your templates reference by uploading their CodeSystem and ValueSet resources over plain FHIR REST (PUT to /fhir/CodeSystem/<id> and /fhir/ValueSet/<id>); value sets are expanded on upload, so validation answers from the pre-computed expansion.

Several terminology servers at once

Real deployments bind to more than one terminology — SNOMED CT from one server, LOINC or a national code system from another. Every entry under [terminology.external.providers] is materialised at startup, and a routing map picks the one that answers each call:

[terminology.external]
enabled = true

[terminology.external.providers.snomed]
type = "fhir"
url = "https://snomed.example.org/fhir"

[terminology.external.providers.loinc]
type = "fhir"
url = "https://loinc.example.org/fhir"

# Terminology namespace -> provider name. Keys are matched whole-string and
# case-insensitively: a terminology id as an archetype binding writes it, a
# code-system URI, or a value-set URL.
[terminology.external.routes]
"SNOMED-CT" = "snomed"
"http://snomed.info/sct" = "snomed"
"http://loinc.org" = "loinc"

Selection is deliberately mechanical, so you can predict which server answers: the caller offers candidate keys in priority order (the value set or coded system first, then the AQL service_api flavour); the first key with a route entry wins; otherwise the provider named default answers — or, when exactly one provider is configured, that one. With two or more providers and no default, an unrouted terminology has no server at all, which is a useful way to make routing mistakes loud instead of silent. A route naming a provider that does not exist fails at startup, never at request time.

The terminology compose profile (development and CI)

The repository’s docker-compose.yml can start a real FHIR R4 terminology server (HAPI FHIR JPA) beside the CDR, seeded with a small set of synthetic test code systems and value sets:

docker compose --profile terminology \
  -f docker-compose.yml -f docker/sut-terminology.yml up

The profile starts the server (host port 8090 by default, FERROEHR_TERMINOLOGY_PORT) plus a one-shot seeding container that uploads the fixtures over the server’s own FHIR API and verifies $validate-code and $expand before exiting. The docker/sut-terminology.yml overlay is what points the CDR at it, by switching on the [terminology.external] providers that docker/ferroehr.dev.toml already carries in the disabled state. Without that overlay the CDR ignores the terminology server entirely, so the plain quickstart is unchanged.

The seeded content is synthetic and lives under the reserved example.test domain: one hierarchical, SNOMED-CT-shaped code system and one LOINC-shaped one, each with an enumerated value set. It carries no licensed terminology content — point the providers at a real server (and, for SNOMED CT, hold the appropriate licence) for anything beyond experimentation.

The stock HAPI image keeps its database in memory, so restarting the terminology container drops the seed; re-run the profile (or just the seeding container) after one.

When the terminology server cannot answer

fail_on_error decides what happens when a bound value set cannot be resolved at all — the server is unreachable, returns an error, or does not know the value set:

  • false (the default, fail-open): the composition is accepted and a warning is logged. Availability of an external service does not block clinical writes.
  • true (fail-closed): the composition is rejected with a validation error naming the unresolved binding.

A code that is resolved and turns out not to be a member of the bound value set is a different matter: that is a real constraint violation and the composition is rejected under either setting.

Terminology in AQL

Query authors can use the AQL TERMINOLOGY() function to constrain a match to a value set — TERMINOLOGY('expand', …) resolves a value set and merges its codes into a matches list at query-analysis time. See Querying with AQL for the query surface. Where an external terminology operation is not yet supported, the engine returns a typed rejection rather than a silent wrong answer.