A Proof of Concept (POC) for a Clinical Terminology Rest Server designed to map proprietary legacy codes to LOINC standards using the HL7 FHIR $translate operation.
This project leverages the HAPI FHIR JPA Server Starter project to provide a robust, standard-compliant server backed by a Postgres relational database.
This repository contains a functional implementation of a Terminology Server capable of defining a LOINC code system, a legacy proprietary code system, and a ConceptMap to perform runtime translations.
It is designed to be easily deployed using Docker containers for the persistence layer.
We use these tools:
-
Java 17 (LTS) or later.
-
Maven 3.9.9+.
-
Docker (recent version).
We want to have a terminology mapping service, able to translate hypothetical legacy codes
to the LOINC system.
So, let’s imagine you have the following legacy codes
| Code | Description |
|---|---|
HOSP-123 |
Group and Screen Test |
HOSP-456 |
Hemoglobin A1c |
Now we want to have a rest service, that will translate these legacy codes, into LOINC codes, like
| Legacy | LOINC | Description |
|---|---|---|
HOSP-123 |
883-9 |
ABO and Rh group [Type] in Blood |
HOSP-456 |
4548-4 |
Hemoglobin A1c/Hemoglobin.total in Blood |
using the hapi fhir $translate operation.
For this POC, translating two codes will suffice.
Open a terminal, and in a directory of your choice, please execute
git clone https://github.com/garneroj/FHIR-Terminology-Mapping-Service-Poc.gitYou should see something like
$ git clone https://github.com/garneroj/FHIR-Terminology-Mapping-Service-Poc.git
Cloning into 'FHIR-Terminology-Mapping-Service-Poc'...
...
$ ls -ltra
total 0
drwxr-x---+ 68 jorgegarnero staff 2176 Dec 30 16:47 ..
drwxr-xr-x 3 jorgegarnero staff 96 Dec 30 16:47 .
drwxr-xr-x 21 jorgegarnero staff 672 Dec 30 16:47 FHIR-Terminology-Mapping-Service-Poc
$
$ cd FHIR-Terminology-Mapping-Service-Poc
$ ls
AGENTS.md catalina.properties custom Dockerfile pom.xml src
build-docker-image.bat charts docker-build.bat LICENSE README.adoc term-server-config
build-docker-image.sh configs docker-compose.yml NOTICE server.xmlREADME.adoc is this same document.
Once you have it, please execute
mvn clean package(libraries download may take a while)
You should see something like:
$ mvn clean package
[INFO] Scanning for projects...
[WARNING]
...
[WARNING] The project ca.uhn.hapi.fhir:hapi-fhir-jpaserver-starter...
[INFO]
[INFO] ------------< ca.uhn.hapi.fhir:hapi-fhir-jpaserver-starter >------------
[INFO] Building HAPI FHIR JPA Server ...
...
[INFO]
[INFO] --- spring-boot:3.4.11:repackage (default) @ hapi-fhir-jpaserver-starter ---
[INFO] ...
[INFO] ...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 18.080 s
[INFO] Finished at: 2025-12-30T17:13:18-05:00
[INFO] ------------------------------------------------------------------------|
Note
|
This terminology server is based on the HAPI FHIR JPA Server Starter. We will analyze the changes this repository has for its purpose. Starting with… |
The application is pre-configured to use PostgreSQL as the persistence layer (replacing the default H2 database).
The configuration settings are located in src/main/resources/application.yaml.
We will ensure (in next instructions) your database matches the following credentials defined in the datasource section:
spring:
datasource:
url: 'jdbc:postgresql://localhost:5432/termdatabase'
username: terminologypostgresuser
password: Pass123
driverClassName: org.postgresql.Driver
jpa:
properties:
hibernate:
dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect|
Important
|
If you wish to change the database name, password, username, port, you must update this file. These values are hardcoded in this POC for simplicity. Do not hardcode credentials in a production environment. |
execute
docker pull postgresThis will pull the latest docker image for postgres.
|
Note
|
Linux users: If you are running docker commands on Linux and have not configured your user to run Docker without privileges (ie., added your user to the 'docker' group), you may need to prepend 'sudo' to the docker commands. Not applicable on macOS or Windows. |
execute
$ docker run -d --name terminologyContainer -p 5432:5432 -e POSTGRES_PASSWORD=Pass123 -e POSTGRES_USER=terminologypostgresuser postgresthis runs:
-
a container called terminologyContainer (--name)
-
which runs in the background (-d)
-
image port 5432 will be mapped to port 5432 on your host machine. (-p)
-
password will be Pass123 (-e)
-
for user terminologypostgresuser (-e)
-
with an image named postgres
|
Note
|
Notice the user, password and port are the same that were indicated in application.yaml file. |
To verify, execute docker ps, and you should find such container.
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c9e157bc1318 postgres "docker-entrypoint.s…" 5 seconds ago Up 5 seconds 0.0.0.0:5432->5432/tcp terminologyContainerOur container is up & running.
Let’s open a terminal in it, call postgres command line tool (psql), and create the database.
$ docker exec -it terminologyContainer bash
root@c9e157bc1318:/# psql -h localhost -U terminologypostgresuser
psql (18.1 (Debian 18.1-1.pgdg13+2))
Type "help" for help.
terminologypostgresuser=# CREATE DATABASE termdatabase;
CREATE DATABASE
terminologypostgresuser=# \c termdatabase;
You are now connected to database "termdatabase" as user "terminologypostgresuser".
termdatabase=#
\q
root@ad79e2955ab3:/#
exit
What's next:
...
$Ok. We have
-
a docker container for postgres =
terminologyContainer -
with database=
termdatabase -
with user=
terminologypostgresuser -
with password=
Pass123
matching the application.yaml values too.
back to your terminal, in the project directory, run
mvn clean spring-boot:runyou should see something like,
$ mvn clean spring-boot:run
[INFO] Scanning for projects...
[WARNING]
...
[WARNING] Some problems were encountered ... for ca.uhn.hapi.fhir:hapi-fhir-jpaserver-starter...
...
[INFO]
[INFO] --- spring-boot:3.4.11:run (default-cli) @ hapi-fhir-jpaserver-starter ---
[INFO] Attaching agents: []
...
...
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.4.11)
2025-12-31T13:55:33.632-05:00 INFO 34549 --- [ main] ca.uhn.fhir.jpa.starter.Application : Starting Application using Java 17.0.13 with PID 34549 (/Users/jorgegarnero/FHIR-Terminology-Mapping-Service-Poc/target/classes started by jorgegarnero in /Users/jorgegarnero/FHIR-Terminology-Mapping-Service-Poc)
...
...
2025-12-31T13:55:44.962-05:00 INFO 34549 --- [ main] org.quartz.core.QuartzScheduler : Scheduler hapi-fhir-jpa-scheduler_$_NON_CLUSTERED started.
2025-12-31T13:55:44.962-05:00 INFO 34549 --- [ main] c.uhn.fhir.jpa.sched.BaseHapiScheduler : Starting scheduler hapi-fhir-jpa-scheduler-clustered
2025-12-31T13:55:44.962-05:00 INFO 34549 --- [ main] org.quartz.core.QuartzScheduler : Scheduler hapi-fhir-jpa-scheduler_$_NON_CLUSTERED started.
2025-12-31T13:55:44.970-05:00 INFO 34549 --- [ main] ca.uhn.fhir.jpa.starter.Application : Started Application in 11.483 seconds (process running for 11.678)You should see the line indicating Started Application, to have the service up & running.
To verify, you can also check its CapabilityStatement or swagger Swagger.
We need to configure the legacy codes, LOINC codes, and their mappings
$ ls -l src/main/resources/terminology-mapping-resources
total 24
-rw-r--r-- 1 jorgegarnero staff 434 Dec 30 16:47 codesystem-internal-hospital-labs.json
-rw-r--r-- 1 jorgegarnero staff 402 Dec 30 16:47 codesystem-loinc-fragment-labs.json
-rw-r--r-- 1 jorgegarnero staff 1089 Dec 30 16:47 conceptmap-internal-hospital-2-loinc-fragment.json| Filename | Meaning |
|---|---|
codesystem-internal-hospital-labs.json |
The two legacy codes we want to map. |
codesystem-loinc-fragment-labs.json |
The two target loinc codes. |
conceptmap-internal-hospital-2-loinc-fragment.json |
The map. |
To do that we will create these code systems and concept map resources.
This script will execute three POST requests to the server.
$ ls -l term-server-config
total 8
-rwxr-xr-x 1 jorgegarnero staff 3013 Dec 30 16:47 term-server-config.sh
$ cd term-server-configPlease run the script (from term-server-config directory). It will skip the load if the resource is already created.
You should read something like:
$ ./term-server-config.sh
==========================================
STARTING TERMINOLOGY CONFIGURATION
==========================================
Checking server status...
Server is online!
------------------------------------------
Checking CodeSystem: http://my-hospital.com/internal-lab-codes
-> Not found. Creating resource from codesystem-internal-hospital-labs.json...
-> [OK] Successfully created (HTTP 201).
------------------------------------------
Checking CodeSystem: http://loinc.org
-> Not found. Creating resource from codesystem-loinc-fragment-labs.json...
-> [OK] Successfully created (HTTP 201).
------------------------------------------
Checking ConceptMap: http://my-hospital.com/fhir/ConceptMap/internal-hospital-2-loinc-fragment
-> Not found. Creating resource from conceptmap-internal-hospital-2-loinc-fragment.json...
-> [OK] Successfully created (HTTP 201).
------------------------------------------
==========================================
CONFIGURATION PROCESS COMPLETED
==========================================Now that the server is up and the mappings are loaded, let’s ask the server to translate one of our legacy codes.
We will try to translate HOSP-123 (which we know is "Group and Screen Test") to see if we get the LOINC code 883-9.
Execute the following curl command:
curl -s "http://localhost:8080/fhir/ConceptMap/\$translate?system=http://my-hospital.com/internal-lab-codes&code=HOSP-123&target=http://loinc.org"You should receive a JSON response similar to this:
{
"resourceType": "Parameters",
"parameter": [ {
"name": "result",
"valueBoolean": true
}, {
"name": "message",
"valueString": "Matches found!"
}, {
"name": "match",
"part": [ {
"name": "equivalence",
"valueCode": "equivalent"
}, {
"name": "concept",
"valueCoding": {
"system": "http://loinc.org",
"code": "883-9",
"display": "ABO and Rh group [Type] in Blood"
}
} ]
} ]
}If you see "valueCode": "883-9", Congratulations! Your Terminology Server is successfully translating legacy proprietary data into standard LOINC codes.
To ensure the system integrity, we have included a suite of integration tests.
The addition of the failsafe plugin makes "verify" a new maven goal for integration tests.
<!-- failsafe plugin added-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.4.0</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
The addition of build helper plugin indicates where to run integration tests from
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>add-integration-test-source</id>
<phase>generate-test-sources</phase>
<goals>
<goal>add-test-source</goal>
</goals>
<configuration>
<sources>
<source>src/integration-test/java</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
Make sure you have the terminologyContainer running, and the termserver app started (as shown before).
From a terminal in the project root directory, execute:
mvn clean verifyYou should see the build success:
[INFO] Results:
[INFO]
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------Under the target/failsafe-reports directory, you can see the test output. You should see something like:
$ cat target/failsafe-reports/com.myhospital.TerminologyIT-output.txt
...
...
2026-01-08T16:02:45.429-05:00 INFO --- [ main] ca.uhn.fhir.util.VersionUtil : HAPI FHIR version 8.6.0 - Rev 66e4809c55
...
✅ Successful connection: HAPI FHIR Server
🔄 Running...
Original: HOSP-123
Expected: 883-9
Actual: 883-9
✅ Successful translation.The TerminologyIT Java class verifies HOSP-123 is successfully translated to 883-9.
You can easily extend this test to check legacy code HOSP-456 is successfully translated to 4548-4.
By leveraging the HAPI FHIR JPA Starter, PostgreSQL, and the standard $translate operation, we have established a foundational architecture for a Terminology Service.
While this project focuses on a specific subset of Lab codes (LOINC), the pattern is reusable for other domains (Diagnoses/ICD-10, Medications/RxNorm), paving the way for a fully interoperable Clinical Data ecosystem.
Notably, no functional code was written, only tests. The mapping function was implemented by configuring HAPI FHIR.