FHIR Integration Patterns for Hospital Information Systems
Practical patterns for integrating with hospital FHIR R4 servers — from authentication to data mapping to real-time subscriptions.
Integrating with hospital information systems is one of the hardest problems in healthtech. Every hospital runs different EHR vendors, different FHIR implementations, and different data quality levels. After integrating with 12 different hospital FHIR R4 servers across Epic, Cerner, and MEDITECH, we've developed patterns that turn this chaos into a reliable integration layer.
This post walks through the patterns we use — concrete, battle-tested approaches to authentication, data mapping, search, real-time subscriptions, error handling, and testing. Each pattern includes code you can adapt to your own integration layer.
Understanding FHIR R4
Fast Healthcare Interoperability Resources (FHIR) is the HL7 standard for exchanging healthcare information electronically. R4 — the fourth normative release — is the version most hospitals run today. It defines a RESTful API over HTTP where every clinical concept is represented as a resource.
Core Resources
You won't need every resource. Most integrations center on a handful:
- Patient — Demographics, identifiers, contact information. The anchor resource for almost every query.
- Encounter — A defined period of care: an admission, outpatient visit, or virtual consult. Ties clinical data to a specific care event.
- Observation — Vital signs, lab results, assessments. The most polymorphic resource — a single type covers everything from blood pressure to PHQ-9 scores.
- MedicationRequest — Prescriptions and medication orders, including dosage, route, and status.
- DiagnosticReport — Imaging reports, pathology results, EKG interpretations. Often references one or more Observation resources.
REST API Patterns
FHIR exposes standard CRUD operations:
GET /Patient/{id} — Read a patient
GET /Patient?name=smith&birthdate=… — Search patients
POST /Patient — Create a patient
PUT /Patient/{id} — Update a patient
DELETE /Patient/{id} — Delete (rare in practice)
In real-world hospital integrations, you'll mostly read and search. Write operations are uncommon and heavily permissioned.
Bundles: Transaction, Batch, Collection
When you need to send multiple resources in a single request, FHIR uses Bundles:
- Transaction — All entries succeed atomically, or all are rolled back. Use for related writes (e.g., creating an Encounter with linked Observations).
- Batch — Entries are processed independently. One failure doesn't block the rest. Good for bulk sync.
- Collection — A group of resources with no processing semantics. Useful for passing multiple resources to an operation.
Extensions and Custom Profiles
Here's where FHIR gets tricky. The base spec defines standard data types, but hospitals routinely add extensions — custom fields bolted onto standard resources. An Epic server might put a parking location in an extension on Encounter. Cerner might put the referring physician in a different extension on the same resource.
Always inspect a hospital's CapabilityStatement to discover which extensions they use, and build your mapper to handle the presence — or absence — of each one.
Authentication Patterns
FHIR servers almost never run unauthenticated. The authentication model depends on whether you're building a user-facing app (SMART on FHIR) or a backend service (client credentials).
SMART on FHIR Launch Sequence
SMART on FHIR extends OAuth 2.0 for clinical apps. The launch sequence works like this:
- Your app is registered with the hospital's authorization server.
- The EHR launches your app with a
launchquery parameter containing a launch context token. - Your app redirects to the authorization endpoint, exchanging the launch token for an access token with scoped permissions.
- The access token is used to query the FHIR server.
OAuth 2.0 with Hospital Identity Providers
Hospitals use their own identity providers — Okta, Azure AD, Ping Identity, or custom solutions. The key is that you don't control the IdP. Your app must tolerate different token formats, different claim names, and different refresh behaviors.
Backend Service Authentication
For server-to-server integrations (e.g., nightly data sync), you'll use OAuth 2.0 client credentials. You register a client ID and private key (typically RSA or EC) with the hospital, and exchange those for an access token at the token endpoint.
Handling Token Refresh and Expiry
Tokens expire. Hospitals set different TTLs — some 5 minutes, some 60. Your integration must refresh proactively, not reactively. Waiting for a 401 wastes a round trip and adds latency to your next request.
Code Example: SMART on FHIR Auth Flow
/**
* Builds the SMART on FHIR authorization URL.
* Exchanges a launch token for an access token.
*/
class SmartAuthClient {
constructor(config) {
this.authorizeEndpoint = config.authorizeEndpoint;
this.tokenEndpoint = config.tokenEndpoint;
this.clientId = config.clientId;
this.redirectUri = config.redirectUri;
this.scopes = config.scopes; // e.g. ['launch', 'openid', 'fhirUser', 'patient/*.read']
}
/**
* Step 1: Build the authorization redirect URL.
* Called when the app is launched by the EHR.
*/
buildAuthorizeUrl(launchToken) {
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: this.scopes.join(' '),
state: crypto.randomUUID(),
aud: this.fhirServerUrl,
launch: launchToken,
});
return `${this.authorizeEndpoint}?${params.toString()}`;
}
/**
* Step 2: Exchange the authorization code for tokens.
* Handles different token response formats across hospitals.
*/
async exchangeCode(authCode) {
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: this.redirectUri,
client_id: this.clientId,
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
patientId: data.patient, // Patient context from SMART launch
idToken: data.id_token,
};
}
/**
* Step 3: Refresh an expired access token.
* Proactively refreshes 30 seconds before expiry.
*/
async refreshToken(refreshToken) {
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.clientId,
}),
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresAt: Date.now() + data.expires_in * 1000,
};
}
}
Data Mapping Strategy
This is the section that matters most. FHIR is a wire format, not a domain model. Every hospital maps FHIR differently — Epic uses different coding systems than Cerner, Cerner uses different extensions than MEDITECH, and every site customizes further.
The Problem: Heterogeneous FHIR Implementations
Here's a concrete example. A "blood pressure" observation might come back as:
// Hospital A: Standard LOINC coding
{ "code": { "coding": [{ "system": "http://loinc.org", "code": "85354-9" }] } }
// Hospital B: Custom code system + extensions
{ "code": { "coding": [{ "system": "http://cerner.com/code", "code": "BP" }] },
"extension": [{ "url": "http://hospital-b.com/ext/systolic", "valueDecimal": 120 }] }
// Hospital C: Component-based (correct per spec, but not universal)
{ "code": { "coding": [{ "system": "http://loinc.org", "code": "85354-9" }] },
"component": [
{ "code": { "coding": [{ "system": "http://loinc.org", "code": "8480-6" }] }, "valueQuantity": { "value": 120 } },
{ "code": { "coding": [{ "system": "http://loinc.org", "code": "8462-4" }] }, "valueQuantity": { "value": 80 } }
] }
Three hospitals, three formats, same clinical concept. Your integration layer must normalize all three into a single domain model.
Solution: Canonical Data Model with Mapping Layer
Introduce a canonical data model — your application's internal representation of clinical data — and a mapping layer that converts FHIR resources into that model. The mapping layer is per-hospital, not per-resource. Write it once, maintain it per vendor.
Code Example: FHIR Resource to Domain Model Mapper
/**
* Maps raw FHIR Observation resources to a normalized domain model.
* Handles LOINC-coded vitals, component-based observations, and
* vendor-specific extensions.
*/
class ObservationMapper {
// LOINC codes for common vitals
static VITAL_LOINC = {
'8480-6': 'systolic_bp', // Systolic blood pressure
'8462-4': 'diastolic_bp', // Diastolic blood pressure
'8867-4': 'heart_rate', // Heart rate
'8310-5': 'temperature', // Body temperature
'9279-1': 'respiratory_rate', // Respiratory rate
'59408-5': 'o2_saturation', // Pulse oximetry
};
/**
* Map a FHIR Observation to a normalized VitalSign object.
* Returns null if the observation can't be mapped.
*/
mapToFhir(observation, hospitalConfig) {
const loincCode = this.extractLoincCode(observation, hospitalConfig);
if (!loincCode || !ObservationMapper.VITAL_LOINC[loincCode]) {
return null; // Unrecognized observation type
}
const vitalType = ObservationMapper.VITAL_LOINC[loincCode];
// Handle component-based observations (blood pressure)
if (observation.component?.length) {
return this.mapComponents(observation, vitalType);
}
// Handle simple value observations
const value = this.extractValue(observation);
if (value === undefined) return null;
return {
type: vitalType,
value: value,
unit: observation.valueQuantity?.unit ?? observation.valueString ?? '',
effectiveDateTime: observation.effectiveDateTime ?? observation.effectivePeriod?.start,
source: observation.id,
status: observation.status,
};
}
/**
* Extract LOINC code, falling back to hospital-specific code mappings
* if the hospital doesn't use standard LOINC.
*/
extractLoincCode(observation, hospitalConfig) {
// 1. Try standard LOINC first
const loincCoding = observation.code?.coding?.find(
c => c.system === 'http://loinc.org'
);
if (loincCoding?.code) return loincCoding.code;
// 2. Fall back to hospital-specific code mapping
const vendorCoding = observation.code?.coding?.[0];
if (vendorCoding && hospitalConfig.codeMapping?.[vendorCoding.code]) {
return hospitalConfig.codeMapping[vendorCoding.code];
}
// 3. Try extensions (Epic pattern)
const extensionValue = observation.extension?.find(
e => e.url === hospitalConfig.vitalTypeExtension
)?.valueCode;
return extensionValue ?? null;
}
/**
* Extract numeric or string value from a FHIR Observation.
*/
extractValue(observation) {
if (observation.valueQuantity?.value !== undefined) {
return observation.valueQuantity.value;
}
if (observation.valueString) return observation.valueString;
if (observation.valueCodeableConcept?.coding?.[0]?.display) {
return observation.valueCodeableConcept.coding[0].display;
}
return undefined;
}
/**
* Map component-based observations (e.g., blood pressure).
*/
mapComponents(observation, parentType) {
if (parentType !== 'systolic_bp') return null;
const systolic = observation.component?.find(c =>
c.code?.coding?.some(code => code.code === '8480-6')
);
const diastolic = observation.component?.find(c =>
c.code?.coding?.some(code => code.code === '8462-4')
);
return {
type: 'blood_pressure',
systolic: systolic?.valueQuantity?.value ?? null,
diastolic: diastolic?.valueQuantity?.value ?? null,
unit: systolic?.valueQuantity?.unit ?? 'mmHg',
effectiveDateTime: observation.effectiveDateTime,
source: observation.id,
status: observation.status,
};
}
}
Handling Missing Data and Null Values
FHIR resources often have missing fields. A Patient might not have a phone number. An Observation might lack an effective date. Your mapper must treat missing data as a first-class concern, not an edge case:
- Return
nullor a sentinel value — never throw — for missing optional fields. - Log unmapped resources for later analysis. Missing data in one hospital might indicate a mapping gap you need to fill.
- Distinguish between "field absent" and "field present but empty." Both are common.
Version Negotiation: FHIR R4 vs R5
Some hospitals are migrating to FHIR R5. While R5 is largely backward-compatible with R4, there are breaking changes — new resource types, retired resources, and changed cardinalities. Your mapper should version-check the CapabilityStatement and adapt accordingly, rather than hardcoding to a single FHIR version.
Search and Pagination
FHIR search is powerful but inconsistent across implementations. A query that works on Epic might not work on Cerner. Understanding the spec — and the deviations — saves hours of debugging.
FHIR Search Parameters
The standard defines search parameters per resource type. For Patient, common parameters include:
GET /Patient?name=Smith // Partial match on name
GET /Patient?birthdate=ge2000-01-01 // Date range (ge = greater or equal)
GET /Patient?identifier=12345 // MRN or other identifier
GET /Patient?family=Smith&given=John // Composite search
GET /Patient?_count=50&_sort=-birthdate // Pagination and sorting
Not all hospitals support all parameters. Check the CapabilityStatement's rest.resource.searchParam for each resource type.
Pagination with Page Tokens
FHIR uses link-based pagination. The response Bundle includes link entries for first, previous, next, and last:
{
"resourceType": "Bundle",
"type": "searchset",
"total": 1247,
"link": [
{ "relation": "self", "url": "/Patient?_getpagesoffset=0&_count=50" },
{ "relation": "next", "url": "/Patient?_getpagesoffset=50&_count=50" },
{ "relation": "last", "url": "/Patient?_getpagesoffset=1200&_count=50" }
],
"entry": [ ... ]
}
Some servers use opaque cursor tokens instead of offset-based pagination. Your pagination logic must follow the next link rather than constructing URLs yourself.
Code Example: Search Query Builder
/**
* Builds FHIR search queries with type-safe parameters.
* Handles date operators, modifiers, and pagination.
*/
class FhirSearchBuilder {
constructor(resourceType) {
this.resourceType = resourceType;
this.params = new URLSearchParams();
}
/** Add a simple string parameter. */
where(param, value) {
this.params.set(param, value);
return this;
}
/** Add a date parameter with an operator (eq, ne, lt, gt, le, ge). */
whereDate(param, operator, value) {
this.params.set(param, `${operator}${value}`);
return this;
}
/** Filter by a reference (e.g., Patient=Patient/123). */
whereReference(param, reference) {
this.params.set(param, reference);
return this;
}
/** Set page size. */
count(n) {
this.params.set('_count', String(n));
return this;
}
/** Set sort field and direction. */
sort(field, direction = 'asc') {
this.params.set('_sort', `${direction === 'desc' ? '-' : ''}${field}`);
return this;
}
/** Set page offset (for offset-based pagination). */
page(offset) {
this.params.set('_getpagesoffset', String(offset));
return this;
}
/** Build the final URL. */
build(baseUrl = '') {
const query = this.params.toString();
return `${baseUrl}/${this.resourceType}${query ? `?${query}` : ''}`;
}
}
// Usage:
const url = new FhirSearchBuilder('Observation')
.whereReference('patient', 'Patient/abc-123')
.where('category', 'vital-signs')
.whereDate('date', 'ge', '2026-01-01')
.count(100)
.sort('date', 'desc')
.build('https://fhir.hospital-a.com');
// → https://fhir.hospital-a.com/Observation?patient=Patient/abc-123
// &category=vital-signs&date=ge2026-01-01&_count=100&_sort=-date
Performance: Avoiding N+1 Queries
The most common FHIR integration mistake: fetching a Patient, then making separate requests for each Encounter, then separate requests for each Observation. This is the N+1 problem applied to healthcare APIs.
Solutions:
- _include parameter — Request related resources inline:
GET /Encounter?patient=123&_include=Encounter:patient - Batch by date range — Pull all Observations for a patient in a date window, not one at a time.
- GraphQL (where supported) — FHIR R4 supports an optional GraphQL endpoint that lets you request nested resources in a single query.
Real-Time Subscriptions
Polling FHIR servers for updates is wasteful and slow. FHIR's Subscription resource enables push-based notifications when data changes.
FHIR Subscription: R4 vs R5
In R4, Subscription supports channel-based notification (typically via a notification server or WebSocket). In R5, the model is more expressive with topic-based subscriptions and explicit channel configuration. Most hospitals still run R4, so we focus on that.
Topic-Based Subscriptions
A subscription declares what events to watch for. The query defines the filter criteria, and the channel defines where notifications go:
{
"resourceType": "Subscription",
"status": "active",
"criteria": "Observation?patient=Patient/abc-123&category=vital-signs",
"channel": {
"type": "websocket",
"endpoint": "wss://fhir.hospital-a.com/subscriptions/abc-123"
}
}
WebSocket-Based Subscription Endpoints
WebSocket subscriptions give you real-time event streams. The flow:
- Register a Subscription resource on the FHIR server.
- Connect to the WebSocket endpoint provided in the subscription.
- Receive event notifications as JSON messages containing the changed resource.
- Acknowledge the notification; the server marks it as delivered.
Handling Subscription Lifecycle
Subscriptions aren't fire-and-forget. They expire, fail, and need maintenance:
- Heartbeat: Most servers send periodic heartbeats. If you miss several, your connection is stale — reconnect.
- Expiry: Subscriptions have an end date. Extend or recreate them before they lapse.
- Error events: A notification might fail if your endpoint is down. The server queues or drops events — know which behavior you're dealing with.
Code Example: Subscription Manager
/**
* Manages FHIR Subscription lifecycle over WebSocket.
* Handles connection, heartbeats, reconnection, and event dispatch.
*/
class FhirSubscriptionManager {
constructor(config) {
this.fhirClient = config.fhirClient;
this.wsUrl = config.wsUrl;
this.onEvent = config.onEvent; // callback(resource)
this.onHeartbeat = config.onHeartbeat; // callback()
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.heartbeatTimeout = 60000;
this.ws = null;
this.heartbeatTimer = null;
}
/**
* Create a Subscription resource on the FHIR server.
*/
async subscribe(criteria, channelType = 'websocket') {
const subscription = {
resourceType: 'Subscription',
status: 'requested',
criteria: criteria,
channel: {
type: channelType,
endpoint: this.wsUrl,
},
};
const response = await this.fhirClient.create('Subscription', subscription);
return response.id;
}
/**
* Open the WebSocket connection and begin receiving events.
*/
connect(subscriptionId) {
this.ws = new WebSocket(`${this.wsUrl}/${subscriptionId}`);
this.ws.onopen = () => {
console.log(`[FHIR Sub] Connected to subscription ${subscriptionId}`);
this.reconnectDelay = 1000;
this.startHeartbeatWatch();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Reset heartbeat timer on any message
this.resetHeartbeatTimer();
if (data.type === 'heartbeat') {
this.onHeartbeat?.();
return;
}
if (data.type === 'event') {
this.onEvent?.(data.payload);
this.acknowledge(data.id);
}
};
this.ws.onclose = () => {
console.log(`[FHIR Sub] Connection closed. Reconnecting in ${this.reconnectDelay}ms`);
this.stopHeartbeatWatch();
setTimeout(() => this.connect(subscriptionId), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
};
this.ws.onerror = (error) => {
console.error('[FHIR Sub] WebSocket error:', error);
};
}
/** Acknowledge receipt of an event. */
acknowledge(eventId) {
this.ws.send(JSON.stringify({ type: 'ack', id: eventId }));
}
/** Start monitoring for missed heartbeats. */
startHeartbeatWatch() {
this.heartbeatTimer = setTimeout(() => {
console.warn('[FHIR Sub] Heartbeat timeout — forcing reconnect');
this.ws.close();
}, this.heartbeatTimeout);
}
resetHeartbeatTimer() {
clearTimeout(this.heartbeatTimer);
this.startHeartbeatWatch();
}
stopHeartbeatWatch() {
clearTimeout(this.heartbeatTimer);
}
/** Gracefully disconnect. */
disconnect() {
this.stopHeartbeatWatch();
this.ws?.close();
}
}
Error Handling and Resilience
Hospital FHIR servers are not your typical SaaS API. They're often under-provisioned, rate-limited, and occasionally offline. Your integration must be resilient by default.
Common FHIR Server Errors
- 429 Too Many Requests — Rate limiting. Hospitals often set aggressive limits (100-500 requests per minute). Respect the
Retry-Afterheader. - 503 Service Unavailable — Server maintenance or overload. Expected during hospital IT windows (typically 2-5 AM).
- 401 Unauthorized — Token expired or revoked. Refresh and retry once.
- 403 Forbidden — Insufficient scope. Your token doesn't have access to the requested resource. Don't retry — re-negotiate permissions.
- 404 Not Found — Resource doesn't exist. Not an error — it's a valid search result with zero matches.
Retry Strategies with Exponential Backoff
For transient errors (429, 503, network timeouts), implement exponential backoff with jitter. Never retry immediately — you'll just pile onto the overloaded server.
Circuit Breaker Pattern for Failing Servers
If a FHIR server is consistently returning errors, stop hammering it. A circuit breaker opens after a configurable threshold of failures and stops sending requests for a cool-down period. This protects both your system and the hospital's.
Graceful Degradation
When a FHIR server is unreachable, your application should still function — with degraded data. Cache the last-known-good responses. Show stale data with a timestamp. Alert the operations team. Don't show a blank screen.
Code Example: Resilient FHIR Client
/**
* Resilient HTTP client for FHIR server communication.
* Implements exponential backoff, circuit breaker, and caching.
*/
class ResilientFhirClient {
constructor(config) {
this.baseUrl = config.baseUrl;
this.authClient = config.authClient;
this.maxRetries = config.maxRetries ?? 3;
this.baseDelay = config.baseDelay ?? 1000;
this.circuitBreaker = {
state: 'closed', // 'closed' | 'open' | 'half-open'
failures: 0,
threshold: 5,
cooldown: 60000,
lastFailure: null,
};
this.cache = new Map();
this.cacheTtl = config.cacheTtl ?? 300000; // 5 minutes default
}
/**
* Make a GET request with retry logic and circuit breaker.
*/
async read(resourceType, id) {
const cacheKey = `${resourceType}/${id}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTtl) {
return cached.data;
}
if (this.circuitBreaker.state === 'open') {
if (Date.now() - this.circuitBreaker.lastFailure > this.circuitBreaker.cooldown) {
this.circuitBreaker.state = 'half-open';
} else {
throw new Error(`Circuit breaker open for ${this.baseUrl} — retry after cooldown`);
}
}
let lastError;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
const token = await this.authClient.getAccessToken();
const response = await fetch(`${this.baseUrl}/${resourceType}/${id}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/fhir+json',
},
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') ?? '30', 10);
await this.sleep(retryAfter * 1000);
continue;
}
if (response.status === 503) {
await this.sleep(this.baseDelay * Math.pow(2, attempt));
continue;
}
if (response.status === 401) {
this.authClient.invalidateToken();
continue; // Token refresh + retry
}
if (!response.ok) {
throw new Error(`FHIR ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.cache.set(cacheKey, { data, timestamp: Date.now() });
this.circuitBreaker.failures = 0;
this.circuitBreaker.state = 'closed';
return data;
} catch (error) {
lastError = error;
if (attempt < this.maxRetries) {
await this.sleep(this.baseDelay * Math.pow(2, attempt) + Math.random() * 500);
}
}
}
this.circuitBreaker.failures++;
this.circuitBreaker.lastFailure = Date.now();
if (this.circuitBreaker.failures >= this.circuitBreaker.threshold) {
this.circuitBreaker.state = 'open';
}
throw lastError;
}
/** Sleep for the specified milliseconds. */
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Testing FHIR Integrations
You can't test against a real hospital's FHIR server during development. You need a testing strategy that covers mapping correctness, error handling, and edge cases without touching production infrastructure.
HAPI FHIR Test Server
The HAPI FHIR server is an open-source reference implementation. Run it locally via Docker or use the public test server at hapi.fhir.org. It supports FHIR R4 out of the box and lets you load synthetic resources for testing.
# Run HAPI FHIR locally with Docker
docker run -d --name hapi-fhir \
-p 8080:8080 \
-e hapi.fhir.default_encoding=json \
-e hapi.fhir.fhir_version=R4 \
hapiproject/hapi:latest
Synthetic Patient Data Generation
Use the Synthea patient generator to create realistic synthetic patient records. Synthea produces complete FHIR Bundles with Patients, Encounters, Observations, MedicationRequests, and more — all internally consistent and clinically plausible.
Integration Test Patterns
Structure your tests in three tiers:
- Unit tests for mappers — Feed raw FHIR JSON into your mapper functions and assert the output matches your canonical model. This is where you catch mapping regressions.
- Integration tests against HAPI — Spin up a HAPI container, load synthetic data, and run your full search/subscription flow end-to-end.
- Contract tests per hospital — Before go-live, run a test suite against the hospital's sandbox (most provide one). Verify that your mapper handles their specific extensions and coding systems.
Contract Testing Between Systems
Write a contract: a JSON document that specifies the expected FHIR response shapes for your key queries. Run this contract against both your test server and the hospital's sandbox. Divergences are integration bugs you can fix before production.
Conclusion
FHIR integration is a marathon, not a sprint. Build your mapping layer early, invest in testing infrastructure, and design for the reality that every hospital's FHIR implementation is slightly different.
The patterns in this post — canonical data models, per-hospital mappers, resilient clients with circuit breakers, proactive token refresh, and contract testing — are the ones that have survived production use across 12 different FHIR servers. They won't make FHIR easy, but they'll make it predictable. And in healthcare integration, predictability is everything.
Start with one hospital. Get the mapping layer right. Validate it with synthetic data and contract tests. Then extend to the next hospital — and watch the mapping layer earn its keep as each new integration gets easier than the last.