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 bundle

FHIR Patient resource (JSON)

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).

Bash
dotnet add package Hl7.Fhir.R4
dotnet add package Hl7.Fhir.Validation.R4
C#
using 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

C#
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.

C#
// 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

C#
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

Bash
dotnet add package NHapi.Model.V25
dotnet add package NHapi.Base
C#
using 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.

C#
// 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.

C#
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.

C#
// 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.

JSON
{
  "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}"]
  }
}
C#
// 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).

C#
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