Healthcare Integration · Lesson 1 of 1
FHIR & HL7 Integration in .NET
Healthcare Integration Landscape
Healthcare data exchange is uniquely complex. Patient data must be:
- Interoperable: systems from different vendors must share data
- Secure: HIPAA/GDPR compliance, patient consent
- Accurate: clinical decisions depend on correct data
- Auditable: full history of who accessed what and when
Two dominant standards define how healthcare systems communicate:
| Standard | Type | Used For | |----------|------|---------| | HL7 v2 | Pipe-delimited messages | Legacy hospital systems (ADT, lab results, orders) | | FHIR R4 | REST + JSON/XML | Modern APIs, mobile apps, cloud integrations |
Most healthcare organisations run both — FHIR for new integrations, HL7 v2 for legacy systems (Epic, Cerner, Meditech).
FHIR Fundamentals
FHIR (Fast Healthcare Interoperability Resources) is a modern REST API standard for healthcare data.
Core concepts
- Resource: a unit of healthcare data — Patient, Observation, Medication, Encounter, etc.
- Server: exposes a FHIR-compliant REST API
- Bundle: a container for multiple resources
- Profile: a constraint on a base resource (e.g., UK Core Patient profile)
- Extension: adding custom fields to a resource
FHIR REST API
GET /Patient/123 → read a patient
POST /Patient → create a patient (server assigns ID)
PUT /Patient/123 → update/replace a patient
PATCH /Patient/123 → partial update
DELETE /Patient/123 → delete (creates a tombstone)
GET /Patient?family=Smith → search
GET /Patient/123/_history → version history
POST / → batch/transaction bundleFHIR Patient resource (JSON)
{
"resourceType": "Patient",
"id": "example-patient-1",
"meta": {
"versionId": "1",
"lastUpdated": "2025-04-13T09:30:00Z",
"profile": ["https://fhir.hl7.org.uk/StructureDefinition/UKCore-Patient"]
},
"identifier": [
{
"system": "https://fhir.nhs.uk/Id/nhs-number",
"value": "9000000009"
}
],
"name": [
{
"use": "official",
"family": "Smith",
"given": ["John", "Paul"]
}
],
"gender": "male",
"birthDate": "1985-03-15",
"address": [
{
"use": "home",
"line": ["42 High Street"],
"city": "Manchester",
"postalCode": "M1 1AA",
"country": "GB"
}
],
"telecom": [
{ "system": "phone", "value": "+44 7700 900000", "use": "mobile" },
{ "system": "email", "value": "john.smith@example.com" }
],
"generalPractitioner": [
{ "reference": "Organization/gp-practice-001" }
]
}Setup — Firely SDK (.NET)
The official .NET FHIR SDK is Firely (formerly Hl7.Fhir.Net).
dotnet add package Hl7.Fhir.R4
dotnet add package Hl7.Fhir.Validation.R4using Hl7.Fhir.Model;
using Hl7.Fhir.Rest;
using Hl7.Fhir.Serialization;
// Create a Patient resource
var patient = new Patient
{
Id = "patient-001",
Identifier = new List<Identifier>
{
new Identifier("https://fhir.nhs.uk/Id/nhs-number", "9000000009")
},
Name = new List<HumanName>
{
new HumanName().WithGiven("John").AndFamily("Smith")
},
Gender = AdministrativeGender.Male,
BirthDate = "1985-03-15",
Active = true,
};
// Serialize to JSON
var serializer = new FhirJsonSerializer(new SerializerSettings { Pretty = true });
string json = serializer.SerializeToString(patient);
// Deserialize from JSON
var parser = new FhirJsonParser();
var parsedPatient = parser.Parse<Patient>(json);
// Access structured data
string nhsNumber = parsedPatient.Identifier
.FirstOrDefault(i => i.System == "https://fhir.nhs.uk/Id/nhs-number")?.Value ?? "";FHIR Client
var client = new FhirClient("https://r4.smarthealthit.org");
// Read a patient
Patient patient = await client.ReadAsync<Patient>("Patient/87a339d0-8cae-418e-89c7-8651e6aab3c6");
// Search patients by NHS number
Bundle results = await client.SearchAsync<Patient>(new[]
{
("identifier", "https://fhir.nhs.uk/Id/nhs-number|9000000009")
});
foreach (var entry in results.Entry)
{
var p = (Patient)entry.Resource;
Console.WriteLine($"{p.Name[0].FamilyElement} - {p.BirthDate}");
}
// Create a new patient
var created = await client.CreateAsync(patient);
Console.WriteLine($"Created: {created.Id}");
// Update patient
patient.Active = false;
var updated = await client.UpdateAsync(patient);
// Get patient with observations
var observation = new Observation
{
Status = ObservationStatus.Final,
Code = new CodeableConcept("http://loinc.org", "8867-4", "Heart rate"),
Subject = new ResourceReference("Patient/patient-001"),
Effective = new FhirDateTime(DateTimeOffset.UtcNow),
Value = new Quantity(72, "beats/min", "http://unitsofmeasure.org")
};
await client.CreateAsync(observation);
// Bundle transaction (atomic: all succeed or all fail)
var bundle = new Bundle { Type = Bundle.BundleType.Transaction };
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = "urn:uuid:patient-temp-id",
Resource = patient,
Request = new Bundle.RequestComponent
{
Method = Bundle.HTTPVerb.POST,
Url = "Patient"
}
});
bundle.Entry.Add(new Bundle.EntryComponent
{
Resource = observation,
Request = new Bundle.RequestComponent
{
Method = Bundle.HTTPVerb.POST,
Url = "Observation"
}
});
var transactionResult = await client.TransactionAsync(bundle);SMART on FHIR Authentication
SMART on FHIR is the standard OAuth2 profile for FHIR APIs.
// backend-to-backend: client credentials flow
public class FhirAuthService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly SmartSettings _settings;
public async Task<string> GetAccessTokenAsync()
{
var client = _httpClientFactory.CreateClient();
// Discover FHIR server capabilities
var capabilityStatement = await client.GetFromJsonAsync<JsonDocument>(
$"{_settings.FhirBaseUrl}/metadata");
var tokenUrl = ExtractTokenUrl(capabilityStatement);
// Request token
var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(new[]
{
("grant_type", "client_credentials"),
("client_id", _settings.ClientId),
("client_secret", _settings.ClientSecret),
("scope", "system/Patient.read system/Observation.read"),
}));
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>();
return tokenResponse!.AccessToken;
}
}
// Register authenticated FHIR client
builder.Services.AddHttpClient<IFhirService, FhirService>(client =>
{
client.BaseAddress = new Uri(settings.FhirBaseUrl);
}).AddHttpMessageHandler<FhirAuthHandler>();
public class FhirAuthHandler : DelegatingHandler
{
private readonly FhirAuthService _auth;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var token = await _auth.GetAccessTokenAsync();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, ct);
}
}Building a FHIR Service
public interface IFhirPatientService
{
Task<PatientDto?> GetByNhsNumberAsync(string nhsNumber, CancellationToken ct = default);
Task<IReadOnlyList<ObservationDto>> GetObservationsAsync(string patientId, CancellationToken ct = default);
Task<string> CreateOrUpdatePatientAsync(PatientDto dto, CancellationToken ct = default);
}
public class FhirPatientService : IFhirPatientService
{
private readonly FhirClient _client;
private readonly IMapper _mapper;
private readonly ILogger<FhirPatientService> _logger;
public async Task<PatientDto?> GetByNhsNumberAsync(string nhsNumber, CancellationToken ct = default)
{
try
{
var bundle = await _client.SearchAsync<Patient>(new[]
{
("identifier", $"https://fhir.nhs.uk/Id/nhs-number|{nhsNumber}")
});
var patient = bundle.Entry
.Select(e => e.Resource as Patient)
.FirstOrDefault(p => p is not null);
return patient is null ? null : _mapper.Map<PatientDto>(patient);
}
catch (FhirOperationException ex) when (ex.Status == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
public async Task<IReadOnlyList<ObservationDto>> GetObservationsAsync(
string patientId, CancellationToken ct = default)
{
var bundle = await _client.SearchAsync<Observation>(new[]
{
("patient", patientId),
("_sort", "-date"),
("_count", "50"),
});
return bundle.Entry
.Select(e => e.Resource as Observation)
.Where(o => o is not null)
.Select(o => _mapper.Map<ObservationDto>(o!))
.ToList();
}
public async Task<string> CreateOrUpdatePatientAsync(PatientDto dto, CancellationToken ct = default)
{
// Search for existing patient by NHS number
var existing = await GetByNhsNumberAsync(dto.NhsNumber, ct);
var fhirPatient = _mapper.Map<Patient>(dto);
if (existing is null)
{
var created = await _client.CreateAsync(fhirPatient);
_logger.LogInformation("Created FHIR Patient {Id} for NHS {NhsNumber}", created.Id, dto.NhsNumber);
return created.Id!;
}
else
{
fhirPatient.Id = existing.FhirId;
await _client.UpdateAsync(fhirPatient);
_logger.LogInformation("Updated FHIR Patient {Id}", existing.FhirId);
return existing.FhirId;
}
}
}HL7 v2 Message Parsing
HL7 v2 is the dominant standard in hospital systems. Messages look like:
MSH|^~\&|SENDING_APP|SENDING_FAC|RECV_APP|RECV_FAC|20250413093000||ADT^A01|MSG001|P|2.5
EVN|A01|20250413093000
PID|1||9000000009^^^NHS^NH||Smith^John^Paul||19850315|M|||42 High Street^^Manchester^^M1 1AA^GBR|||||||||||||||
PV1|1|I|Ward7^Bed12^Room2||||12345^Jones^Sarah^Dr^MD|||MED||||ADM|||||||||||||||||||||||||20250413|||Parsing with NHapiTools
dotnet add package NHapi.Model.V25
dotnet add package NHapi.Baseusing NHapi.Base.Parser;
using NHapi.Model.V25.Message;
using NHapi.Model.V25.Segment;
public class Hl7MessageParser
{
private readonly PipeParser _parser = new();
public AdtAdmissionDto? ParseAdt(string rawMessage)
{
try
{
var message = _parser.Parse(rawMessage);
if (message is not ADT_A01 adt) return null;
var pid = adt.PID;
var pv1 = adt.PV1;
return new AdtAdmissionDto
{
// PID-3: patient identifier list
NhsNumber = pid.GetPatientIdentifierList(0).IDNumber.Value,
// PID-5: patient name
FamilyName = pid.GetPatientName(0).FamilyName.Surname.Value,
GivenName = pid.GetPatientName(0).GivenName.Value,
// PID-7: date of birth
DateOfBirth = DateTime.ParseExact(
pid.DateTimeOfBirth.Time.Value, "yyyyMMdd", null),
// PID-8: sex
Gender = pid.AdministrativeSex.Value,
// PV1: visit information
WardCode = pv1.AssignedPatientLocation.PointOfCare.Value,
AdmissionDate = DateTime.ParseExact(
pv1.AdmitDateTime.Time.Value, "yyyyMMddHHmmss", null),
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse HL7 message");
return null;
}
}
// Parse lab result (ORU^R01)
public LabResultDto? ParseLabResult(string rawMessage)
{
var message = _parser.Parse(rawMessage);
if (message is not ORU_R01 oru) return null;
var pid = oru.GetPATIENT_RESULT(0).PATIENT.PID;
var obr = oru.GetPATIENT_RESULT(0).GetORDER_OBSERVATION(0).OBR;
var obx = oru.GetPATIENT_RESULT(0).GetORDER_OBSERVATION(0).GetOBSERVATION(0).OBX;
return new LabResultDto
{
NhsNumber = pid.GetPatientIdentifierList(0).IDNumber.Value,
TestCode = obr.UniversalServiceIdentifier.Identifier.Value,
TestName = obr.UniversalServiceIdentifier.Text.Value,
Result = obx.GetObservationValue(0).Data?.ToString() ?? "",
Units = obx.Units.Identifier.Value,
ReferenceRange = obx.ReferencesRange.Value,
AbnormalFlag = obx.GetAbnormalFlags(0).Value,
ResultDate = DateTime.ParseExact(obx.DateTimeOfTheObservation.Time.Value, "yyyyMMddHHmmss", null),
};
}
// Build HL7 ACK (acknowledgement)
public string BuildAck(string messageControlId, string ackCode = "AA")
{
var ack = new ACK();
ack.MSH.SendingApplication.NamespaceID.Value = "INTEGRATION_ENGINE";
ack.MSH.MessageType.MessageCode.Value = "ACK";
ack.MSH.MessageControlID.Value = Guid.NewGuid().ToString("N")[..20];
ack.MSA.AcknowledgmentCode.Value = ackCode;
ack.MSA.MessageControlID.Value = messageControlId;
return _parser.Encode(ack);
}
}HL7 MLLP Listener (TCP)
HL7 v2 messages are sent over MLLP (Minimal Lower Layer Protocol) — a simple TCP framing protocol.
// MLLP framing: VT + message + FS + CR
// VT = 0x0B (vertical tab)
// FS = 0x1C (file separator)
// CR = 0x0D (carriage return)
public class MllpListener : BackgroundService
{
private const byte VT = 0x0B;
private const byte FS = 0x1C;
private const byte CR = 0x0D;
private readonly ILogger<MllpListener> _logger;
private readonly Hl7MessageParser _parser;
private readonly IServiceScopeFactory _scopeFactory;
private TcpListener? _tcpListener;
protected override async Task ExecuteAsync(CancellationToken ct)
{
_tcpListener = new TcpListener(IPAddress.Any, 2575);
_tcpListener.Start();
_logger.LogInformation("MLLP listener started on port 2575");
while (!ct.IsCancellationRequested)
{
var client = await _tcpListener.AcceptTcpClientAsync(ct);
_ = HandleClientAsync(client, ct); // handle each connection concurrently
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
{
using var stream = client.GetStream();
var buffer = new byte[65536];
var messageBuffer = new List<byte>();
while (!ct.IsCancellationRequested)
{
int bytesRead = await stream.ReadAsync(buffer, ct);
if (bytesRead == 0) break;
for (int i = 0; i < bytesRead; i++)
{
if (buffer[i] == VT)
{
messageBuffer.Clear();
}
else if (buffer[i] == FS)
{
var rawMessage = Encoding.UTF8.GetString(messageBuffer.ToArray());
await ProcessMessageAsync(rawMessage, stream, ct);
messageBuffer.Clear();
}
else
{
messageBuffer.Add(buffer[i]);
}
}
}
}
private async Task ProcessMessageAsync(string rawMessage, NetworkStream stream, CancellationToken ct)
{
string messageControlId = "UNKNOWN";
try
{
messageControlId = ExtractMessageControlId(rawMessage);
_logger.LogInformation("Received HL7 message: {MessageControlId}", messageControlId);
// Route by message type
var msgType = ExtractMessageType(rawMessage);
using var scope = _scopeFactory.CreateScope();
switch (msgType)
{
case "ADT^A01": // admission
case "ADT^A08": // update patient info
var adtDto = _parser.ParseAdt(rawMessage);
if (adtDto is not null)
{
var service = scope.ServiceProvider.GetRequiredService<IPatientSyncService>();
await service.SyncAdmissionAsync(adtDto, ct);
}
break;
case "ORU^R01": // lab result
var labDto = _parser.ParseLabResult(rawMessage);
if (labDto is not null)
{
var service = scope.ServiceProvider.GetRequiredService<ILabResultService>();
await service.ProcessLabResultAsync(labDto, ct);
}
break;
}
// Send ACK
var ack = _parser.BuildAck(messageControlId, "AA");
var ackBytes = Encoding.UTF8.GetBytes($"{(char)VT}{ack}{(char)FS}{(char)CR}");
await stream.WriteAsync(ackBytes, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing HL7 message {MessageControlId}", messageControlId);
var nak = _parser.BuildAck(messageControlId, "AE"); // AE = Application Error
var nakBytes = Encoding.UTF8.GetBytes($"{(char)VT}{nak}{(char)FS}{(char)CR}");
await stream.WriteAsync(nakBytes, ct);
}
}
}HL7 to FHIR Mapping Pipeline
A common requirement: receive HL7 v2 from a hospital, transform to FHIR, write to a FHIR server.
public class Hl7ToFhirPipeline
{
private readonly Hl7MessageParser _hl7Parser;
private readonly IFhirPatientService _fhirService;
private readonly IMapper _mapper;
public async Task ProcessAdmissionAsync(string hl7Message, CancellationToken ct)
{
// 1. Parse HL7
var admission = _hl7Parser.ParseAdt(hl7Message);
if (admission is null)
throw new InvalidOperationException("Failed to parse ADT message");
// 2. Map to FHIR Patient
var patientDto = new PatientDto
{
NhsNumber = admission.NhsNumber,
FamilyName = admission.FamilyName,
GivenNames = new[] { admission.GivenName },
DateOfBirth = admission.DateOfBirth,
Gender = MapGender(admission.Gender),
};
// 3. Upsert to FHIR server
string fhirId = await _fhirService.CreateOrUpdatePatientAsync(patientDto, ct);
// 4. Create FHIR Encounter for the admission
var encounter = new Encounter
{
Status = Encounter.EncounterStatus.InProgress,
Class = new Coding("http://terminology.hl7.org/CodeSystem/v3-ActCode", "IMP", "inpatient encounter"),
Subject = new ResourceReference($"Patient/{fhirId}"),
Period = new Period { Start = admission.AdmissionDate.ToString("yyyy-MM-ddTHH:mm:ssZ") },
Location = new List<Encounter.LocationComponent>
{
new Encounter.LocationComponent
{
Location = new ResourceReference { Display = $"{admission.WardCode}" }
}
}
};
await _fhirService.CreateEncounterAsync(encounter, ct);
}
}Azure FHIR Service
Azure Health Data Services includes a managed FHIR server.
// appsettings.json
{
"FhirServer": {
"BaseUrl": "https://your-workspace.fhir.azurehealthcareapis.com",
"TenantId": "your-tenant-id",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret"
}
}
// Program.cs — register with Azure credential
builder.Services.AddSingleton<FhirClient>(sp =>
{
var settings = sp.GetRequiredService<IOptions<FhirSettings>>().Value;
var credential = new ClientSecretCredential(
settings.TenantId, settings.ClientId, settings.ClientSecret);
var httpClient = new HttpClient(new BearerTokenHandler(credential,
"https://healthcareapis.azure.com/.default"));
return new FhirClient(settings.BaseUrl, httpClient);
});FHIR Subscription (Webhooks)
FHIR Subscriptions notify your system when data changes on the FHIR server.
{
"resourceType": "Subscription",
"status": "active",
"reason": "Notify on new observations",
"criteria": "Observation?patient=Patient/123",
"channel": {
"type": "rest-hook",
"endpoint": "https://your-api.com/fhir/webhooks",
"payload": "application/fhir+json",
"header": ["Authorization: Bearer {token}"]
}
}// Webhook handler
[HttpPost("/fhir/webhooks")]
public async Task<IActionResult> HandleFhirNotification(
[FromBody] Bundle bundle,
CancellationToken ct)
{
foreach (var entry in bundle.Entry)
{
if (entry.Resource is Observation obs)
{
await _observationProcessor.ProcessAsync(obs, ct);
}
}
return Ok();
}Audit and Compliance
FHIR mandates an audit trail. Every access to patient data must be logged (HIPAA, GDPR).
public class FhirAuditService
{
private readonly FhirClient _client;
public async Task LogAccessAsync(
string userId, string patientId, string action, string resourceType)
{
var auditEvent = new AuditEvent
{
Type = new Coding("http://terminology.hl7.org/CodeSystem/audit-event-type", "rest"),
Recorded = DateTimeOffset.UtcNow,
Outcome = AuditEvent.AuditEventOutcome.N0, // success
Agent = new List<AuditEvent.AgentComponent>
{
new AuditEvent.AgentComponent
{
Who = new ResourceReference($"Practitioner/{userId}"),
Requestor = true,
Network = new AuditEvent.NetworkComponent
{
Address = "192.168.1.1",
Type = AuditEvent.AuditEventAgentNetworkType.N2,
}
}
},
Entity = new List<AuditEvent.EntityComponent>
{
new AuditEvent.EntityComponent
{
What = new ResourceReference($"Patient/{patientId}"),
Role = new Coding("http://terminology.hl7.org/CodeSystem/object-role", "1"),
}
},
};
await _client.CreateAsync(auditEvent);
}
}Production Architecture
Hospital Systems (Epic/Cerner)
│ HL7 v2 over MLLP (TCP 2575)
▼
┌─────────────────────────────────────────────────────────┐
│ Integration Engine (.NET) │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ MLLP Listener│──►│ HL7 Parser │──►│ FHIR Mapper │ │
│ │ (Port 2575) │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────┬──────┘ │
│ │ │
│ ┌────────────────────────────────────────────▼──────┐ │
│ │ Service Bus / Queue │ │
│ │ (decouple ingestion from processing) │ │
│ └────────────────────────────────────────────┬──────┘ │
│ │ │
│ ┌────────────────────────────────────────────▼──────┐ │
│ │ FHIR Transform & Publish Worker │ │
│ └────────────────────────────────────────────┬──────┘ │
└───────────────────────────────────────────────┼──────────┘
│
▼
Azure Health Data Services
(FHIR R4 Server)
│
┌─────────────────┼────────────────────┐
▼ ▼ ▼
Analytics Clinical Apps Patient Portal
(Power BI, Synapse) (Web/Mobile) (SMART on FHIR)What to Learn Next
- Observability & Reliability: add structured logging and monitoring to your integration
- Azure Data Factory: build ETL pipelines for healthcare analytics
- Security & Compliance: GDPR, data encryption, role-based access for clinical data