FHIR Queries
ImmunizationFhirService owns all HAPI FHIR queries. A shared FhirContext bean (R4, configured at startup) builds clients per request — no connection pooling complexity, HAPI manages timeouts internally (connect: 10 s, socket: 30 s).
Every method is @Cacheable by patientId. A cache miss triggers a real HAPI call; a hit returns from Caffeine in microseconds.
Vaccination history
Bundle bundle = client.search()
.forResource(Immunization.class)
.where(Immunization.PATIENT.hasId(ctx.getPatientId()))
.where(Immunization.STATUS.exactly().code("completed"))
.sort().descending(Immunization.DATE)
.returnBundle(Bundle.class)
.execute();
FHIR request:
GET /fhir/Immunization?patient=ePatient-8675309&status=completed&_sort=-date
Authorization: Bearer {access_token}
Only status=completed records are returned. entered-in-error and not-done records are excluded — they must not appear in the vaccination history or certificate. Results are sorted newest-first (_sort=-date).
The toVaccinationRecord() mapper extracts protocolApplied[0] for dose number and series name, performer[0].actor for the administering facility, and occurrenceDateTimeType for the date (converted to LocalDate via ZoneId.systemDefault()).
ImmunizationRecommendation
Bundle bundle = client.search()
.forResource(ImmunizationRecommendation.class)
.where(ImmunizationRecommendation.PATIENT.hasId(ctx.getPatientId()))
.returnBundle(Bundle.class)
.execute();
FHIR request:
GET /fhir/ImmunizationRecommendation?patient=ePatient-8675309
Authorization: Bearer {access_token}
Each ImmunizationRecommendation resource can contain multiple recommendation entries. The mapper iterates rec.getRecommendation() and produces one VaccineRecommendation per entry.
Date criteria are extracted by code:
| Code | Meaning |
|---|---|
30100 or Earliest-antigens-dose-date | dateCriterionDue |
30101 or Latest-antigens-dose-date | dateCriterionLatest |
Results are sorted overdue-first, then by dateCriterionDue ascending (soonest due at the top).
Patient demographics
Patient patient = client.read()
.resource(Patient.class)
.withId(ctx.getPatientId())
.execute();
FHIR request:
GET /fhir/Patient/ePatient-8675309
Authorization: Bearer {access_token}
Used on the certificate page for the patient header and by buildSummary() for the dashboard subtitle. Returns a HAPI Patient object directly — not mapped to a custom record since the full Patient resource is passed to the Thymeleaf template.
Summary aggregation
buildSummary() is not a separate FHIR query — it calls the three cached methods and counts from the results:
public ImmunizationSummary buildSummary(SmartLaunchContext ctx) {
List<VaccinationRecord> history = getVaccinationHistory(ctx); // cache hit
List<VaccineRecommendation> recs = getRecommendations(ctx); // cache hit
Patient patient = getPatient(ctx); // cache hit
long overdueCount = recs.stream().filter(VaccineRecommendation::isOverdue).count();
long dueSoonCount = recs.stream().filter(VaccineRecommendation::isDueWithin30Days).count();
return new ImmunizationSummary(
history.size(), recs.size(),
(int) overdueCount, (int) dueSoonCount,
patientDisplayName(patient),
...
);
}
The dashboard GET /dashboard call hits HAPI at most once per resource type per session — three initial cache-miss calls followed by Caffeine hits for every subsequent page load.
Error handling
All query methods catch Exception broadly:
} catch (Exception ex) {
log.error("Failed to load immunization history for patient={}: {}",
ctx.getPatientId(), ex.getMessage());
return List.of();
}
A HAPI error (403 from the scope interceptor, 404 for missing patient, network timeout) returns an empty list. The template handles empty lists by showing a "No records found" message. This is the correct clinical behaviour — a connectivity issue should not crash the app or show an error page when the user might still be able to use other views.
Next: VDS-NC Certificates →