Membangun Sistem HL7 v2 & FHIR dengan Spring Boot dari Nol hingga SATUSEHAT
🏥 Hands-On Series — HealthTech untuk Java Developer
Panduan praktis end-to-end: setup project, parse HL7 v2 via MLLP, CRUD FHIR R4, konversi HL7→FHIR, integrasi SATUSEHAT sandbox, dan Docker Compose siap jalan.
Di artikel sebelumnya kita sudah memahami apa itu HL7 v2 dan FHIR secara konseptual. Sekarang waktunya kita bangun sistemnya dari nol — bukan sekadar snippet, tapi project yang benar-benar bisa dijalankan dan menjadi fondasi proyek nyata Anda.
Kita akan membangun sebuah Healthcare Integration Service — layanan yang menerima pesan HL7 v2 dari SIMRS lama, mengkonversinya ke FHIR R4, menyimpannya ke FHIR server lokal, lalu melaporkannya ke SATUSEHAT.
┌─────────────┐ HL7 v2/MLLP ┌──────────────────────────────────────────────┐
│ SIMRS Lama │ ───────────────▶ │ Healthcare Integration Service │
│ (Simulator) │ port 2575 │ │
└─────────────┘ │ ┌─────────────┐ ┌────────────────────┐ │
│ │ MLLP │────▶│ HL7v2→FHIR │ │
│ │ Listener │ │ Transformer │ │
│ │ (Camel) │ └─────────┬──────────┘ │
│ └─────────────┘ │ │
│ ┌─────────▼──────────┐ │
│ │ FHIR Service │ │
│ │ (HAPI FHIR) │ │
│ └──────┬──────┬──────┘ │
└──────────────────────────────┼──────┼─────────┘
│ │
┌───────────────────────┘ │
▼ ▼
┌────────────┐ ┌──────────────────┐
│ HAPI FHIR │ │ SATUSEHAT API │
│ JPA Server │ │ (Kemenkes) │
│ (lokal) │ └──────────────────┘
└────────────┘
Prerequisites
Artikel ini mengasumsikan Anda sudah familiar dengan Spring Boot dan Gradle. Yang perlu disiapkan:
| Tool | Versi Minimum | Keterangan |
|---|---|---|
| JDK | 21 (LTS) | Gunakan Amazon Corretto atau Eclipse Temurin |
| Gradle | 8.x | Atau gunakan Gradle Wrapper (direkomendasikan) |
| Docker Desktop | 24.x | Untuk menjalankan HAPI FHIR Server + PostgreSQL |
| IntelliJ IDEA | 2024.x | Atau IDE pilihan Anda |
| Akun SATUSEHAT | — | Daftar di platform.satusehat.kemkes.go.id (gratis) |
Bagian 1 — Setup Project Spring Boot
Struktur Project
Kita pakai single-module project dengan package yang jelas per domain:
├── src/main/java/id/yusuf/healthcareis/
│ ├── HealthcareIntegrationServiceApplication.java
│ ├── config/
│ │ ├── FhirConfig.java ── HAPI FHIR beans
│ │ └── CamelConfig.java ── Apache Camel setup
│ ├── hl7/
│ │ ├── Hl7MllpRoute.java ── MLLP listener (Camel route)
│ │ ├── Hl7MessageProcessor.java ── dispatch per message type
│ │ └── Hl7AckBuilder.java ── build ACK response
│ ├── fhir/
│ │ ├── PatientService.java
│ │ ├── EncounterService.java
│ │ └── ObservationService.java
│ ├── transformer/
│ │ └── Hl7ToFhirTransformer.java ── core konversi HL7→FHIR
│ └── satusehat/
│ ├── SatuSehatAuthService.java ── OAuth2 token management
│ └── SatuSehatClient.java ── kirim resource ke SATUSEHAT
├── src/main/resources/
│ └── application.yml
├── src/test/
│ └── ...test files...
├── build.gradle
├── docker-compose.yml
└── settings.gradle
build.gradle
Ini adalah jantung proyek kita. Perhatikan bagaimana semua library dikelompokkan:
📄 build.gradleplugins {
id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
id 'java'
}
group = 'id.yusuf'
version = '1.0.0-SNAPSHOT'
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
configurations {
compileOnly { extendsFrom annotationProcessor }
}
repositories {
mavenCentral()
}
ext {
hapiFhirVersion = '7.4.0'
hapiHl7Version = '2.5.1'
camelVersion = '4.6.0'
}
dependencies {
// ── Spring Boot Core ──────────────────────────────────────────
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// ── Apache Camel (MLLP transport + HL7 parsing) ───────────────
implementation "org.apache.camel.springboot:camel-spring-boot-starter:${camelVersion}"
implementation "org.apache.camel.springboot:camel-hl7-starter:${camelVersion}"
implementation "org.apache.camel.springboot:camel-mllp-starter:${camelVersion}"
implementation "org.apache.camel.springboot:camel-jackson-starter:${camelVersion}"
// ── HAPI HL7 v2 (model + parser) ─────────────────────────────
implementation "ca.uhn.hapi:hapi-base:${hapiHl7Version}"
implementation "ca.uhn.hapi:hapi-structures-v251:${hapiHl7Version}"
// ── HAPI FHIR R4 (toolkit + REST client) ─────────────────────
implementation "ca.uhn.hapi.fhir:hapi-fhir-base:${hapiFhirVersion}"
implementation "ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${hapiFhirVersion}"
implementation "ca.uhn.hapi.fhir:hapi-fhir-client:${hapiFhirVersion}"
implementation "ca.uhn.hapi.fhir:hapi-fhir-validation:${hapiFhirVersion}"
implementation "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-r4:${hapiFhirVersion}"
// ── Database ──────────────────────────────────────────────────
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'com.h2database:h2' // untuk dev/test
// ── HTTP Client untuk SATUSEHAT API ───────────────────────────
implementation 'org.springframework.boot:spring-boot-starter-webflux' // WebClient
// ── Utilities ─────────────────────────────────────────────────
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'com.fasterxml.jackson.core:jackson-databind'
// ── Test ──────────────────────────────────────────────────────
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation "org.apache.camel:camel-test-spring-junit5:${camelVersion}"
}
application.yml
📄 src/main/resources/application.ymlspring:
application:
name: healthcare-integration-service
datasource:
url: jdbc:postgresql://localhost:5432/healthdb
username: healthuser
password: healthpass
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
# ── MLLP Listener ────────────────────────────────────────────────
mllp:
port: 2575
bind-address: "0.0.0.0"
# ── HAPI FHIR — Local FHIR JPA Server ────────────────────────────
fhir:
server:
base-url: http://localhost:8090/fhir # HAPI FHIR Docker container
validation:
enabled: true
# ── SATUSEHAT API Sandbox ─────────────────────────────────────────
satusehat:
base-url: https://api-satusehat.kemkes.go.id/fhir-r4/v1
auth-url: https://api-satusehat.kemkes.go.id/oauth2/v1/accesstoken
client-id: "your-client-id-here"
client-secret: "your-client-secret-here"
organization-id: "your-org-id-here" # ID faskes di SATUSEHAT
enabled: true # false = hanya kirim ke local
# ── Actuator ──────────────────────────────────────────────────────
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
# ── Logging ───────────────────────────────────────────────────────
logging:
level:
id.yusuf: DEBUG
ca.uhn.hl7v2: WARN
ca.uhn.fhir: WARN
org.apache.camel: INFO
# ── Profile: dev (H2 in-memory, SATUSEHAT disabled) ──────────────
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:healthdb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
jpa:
properties:
hibernate.dialect: org.hibernate.dialect.H2Dialect
satusehat:
enabled: false
Bagian 2 — Menerima & Parsing Pesan HL7 v2 via MLLP
MLLP (Minimum Lower Layer Protocol) adalah transport layer di atas TCP yang digunakan HL7 v2. Setiap pesan dibungkus dengan karakter kontrol khusus: 0x0B (VT) sebagai pembuka dan 0x1C 0x0D sebagai penutup. Apache Camel menangani semua ini secara otomatis.
MLLP Listener — Camel Route
📄 hl7/Hl7MllpRoute.java@Component
@RequiredArgsConstructor
public class Hl7MllpRoute extends RouteBuilder {
private final Hl7MessageProcessor messageProcessor;
@Value("${mllp.port}")
private int mllpPort;
@Override
public void configure() {
// ── Error handler: log dan kirim NACK jika gagal ──────────
onException(Exception.class)
.handled(true)
.log(LoggingLevel.ERROR, "HL7 processing error: ${exception.message}")
.process(exchange -> {
Message failed = exchange.getProperty(
"CamelHL7Message", Message.class);
if (failed != null) {
exchange.getIn().setBody(
Hl7AckBuilder.buildNack(failed, exchange.getProperty(
Exchange.EXCEPTION_CAUGHT, Exception.class))
);
}
});
// ── Main Route: MLLP Inbound ──────────────────────────────
from("mllp://0.0.0.0:" + mllpPort + "?autoAck=false")
.routeId("hl7-mllp-inbound")
.log("[MLLP] Received message from ${header.CamelMllpRemoteAddress}")
// Parse pipe-delimited text → HAPI Message object
.unmarshal(HL7.hl7())
// Simpan ke exchange property untuk error handler
.process(e -> e.setProperty("CamelHL7Message", e.getIn().getBody(Message.class)))
// Dispatch ke processor, dapatkan ACK kembali
.bean(messageProcessor, "process")
// Marshal ACK kembali ke string untuk dikirim via MLLP
.marshal(HL7.hl7());
}
}
Message Processor — Dispatch per Tipe Pesan
📄 hl7/Hl7MessageProcessor.java@Component
@RequiredArgsConstructor
@Slf4j
public class Hl7MessageProcessor {
private final Hl7ToFhirTransformer transformer;
private final PatientService patientService;
private final EncounterService encounterService;
private final ObservationService observationService;
private final SatuSehatClient satuSehatClient;
public Message process(Message hl7Msg) throws HL7Exception {
String msgType = hl7Msg.getName(); // ex: "ADT_A04", "ORU_R01"
log.info("[HL7] Processing: {}", msgType);
try {
switch (msgType) {
case "ADT_A01": // Admit
case "ADT_A04": // Register outpatient
case "ADT_A08": // Update patient info
processAdtMessage((ADT_A01) hl7Msg);
break;
case "ORU_R01": // Lab / observation results
processOruMessage((ORU_R01) hl7Msg);
break;
default:
log.warn("[HL7] Unhandled message type: {}", msgType);
}
} catch (Exception ex) {
log.error("[HL7] Failed to process {}: {}", msgType, ex.getMessage(), ex);
return Hl7AckBuilder.buildNack(hl7Msg, ex);
}
return Hl7AckBuilder.buildAck(hl7Msg);
}
private void processAdtMessage(ADT_A01 adt) throws Exception {
// 1. Konversi PID → FHIR Patient
Patient fhirPatient = transformer.toPatient(adt.getPID());
// 2. Upsert ke FHIR server lokal
MethodOutcome outcome = patientService.upsert(fhirPatient);
String patientId = outcome.getId().getIdPart();
// 3. Konversi PV1 → FHIR Encounter
Encounter encounter = transformer.toEncounter(adt.getPV1(), patientId);
encounterService.create(encounter);
// 4. Kirim ke SATUSEHAT (async, tidak block ACK)
satuSehatClient.sendPatientAsync(fhirPatient);
}
private void processOruMessage(ORU_R01 oru) throws Exception {
// ORU_R01 bisa punya multiple PATIENT_RESULT groups
for (int i = 0; i < oru.getPATIENT_RESULTReps(); i++) {
ORU_R01_PATIENT_RESULT pr = oru.getPATIENT_RESULT(i);
String patientId = transformer.extractPatientId(pr.getPATIENT().getPID());
// Setiap ORDER_OBSERVATION bisa punya banyak OBX
for (int j = 0; j < pr.getORDER_OBSERVATIONReps(); j++) {
ORU_R01_ORDER_OBSERVATION oo = pr.getORDER_OBSERVATION(j);
for (int k = 0; k < oo.getOBSERVATIONReps(); k++) {
OBX obx = oo.getOBSERVATION(k).getOBX();
Observation obs = transformer.toObservation(obx, patientId);
observationService.create(obs);
satuSehatClient.sendObservationAsync(obs);
}
}
}
}
}
ACK Builder — Respons Wajib ke Pengirim
HL7 v2 menggunakan mekanisme ACK/NACK — setiap pesan yang diterima wajib dibalas. Jika tidak dibalas, pengirim akan menganggap terjadi kegagalan dan mencoba kirim ulang.
📄 hl7/Hl7AckBuilder.java@UtilityClass
public class Hl7AckBuilder {
public static Message buildAck(Message original) throws HL7Exception, IOException {
return DefaultApplication.makeACK((Segment) original.get("MSH"));
}
public static Message buildNack(Message original, Exception cause) {
try {
Message ack = DefaultApplication.makeACK((Segment) original.get("MSH"));
// Set MSA-1 ke "AE" (Application Error) dan isi error text
Segment msa = (Segment) ack.get("MSA");
Terser terser = new Terser(ack);
terser.set("MSA-1", "AE");
terser.set("MSA-3", cause.getMessage() != null
? cause.getMessage().substring(0, Math.min(80, cause.getMessage().length()))
: "Unknown error");
return ack;
} catch (Exception ex) {
throw new RuntimeException("Cannot build NACK", ex);
}
}
}
Bagian 3 — FHIR R4: Konfigurasi & Services
FhirConfig — Bean Konfigurasi
FhirContext adalah objek besar yang mahal untuk dibuat — inisialisasi membutuhkan ~2-3 detik dan ~50MB RAM. Selalu jadikan sebagai singleton Spring bean.
📄 config/FhirConfig.java@Configuration
public class FhirConfig {
@Value("${fhir.server.base-url}")
private String fhirBaseUrl;
@Value("${fhir.validation.enabled:true}")
private boolean validationEnabled;
/**
* FhirContext: thread-safe, buat sekali, pakai selamanya.
* R4 = FHIR Release 4 — versi yang digunakan SATUSEHAT.
*/
@Bean
@Singleton
public FhirContext fhirContext() {
FhirContext ctx = FhirContext.forR4();
// Timeout untuk koneksi ke FHIR server
ctx.getRestfulClientFactory().setSocketTimeout(30_000);
ctx.getRestfulClientFactory().setConnectTimeout(10_000);
return ctx;
}
/**
* IGenericClient: REST client ke HAPI FHIR server lokal.
* Thread-safe — bisa di-share antar bean.
*/
@Bean
public IGenericClient localFhirClient(FhirContext ctx) {
return ctx.newRestfulGenericClient(fhirBaseUrl);
}
/**
* FhirValidator: validasi resource sebelum dikirim.
* Aktifkan hanya di development — agak lambat.
*/
@Bean
@ConditionalOnProperty(name = "fhir.validation.enabled", havingValue = "true")
public FhirValidator fhirValidator(FhirContext ctx) {
FhirValidator validator = ctx.newValidator();
validator.registerValidatorModule(new FhirInstanceValidator(ctx));
return validator;
}
}
PatientService — Operasi FHIR Patient
📄 fhir/PatientService.java@Service
@RequiredArgsConstructor
@Slf4j
public class PatientService {
private final IGenericClient fhirClient;
private final FhirContext fhirContext;
/**
* Upsert: jika NIK sudah ada → update, jika belum → create.
* Ini pola umum di integrasi SIMRS karena ADT^A08 (update)
* kadang datang sebelum ADT^A01 (create).
*/
public MethodOutcome upsert(Patient patient) {
// Cari berdasarkan NIK
String nik = extractNik(patient);
if (nik != null) {
Bundle existing = fhirClient.search()
.forResource(Patient.class)
.where(Patient.IDENTIFIER.exactly()
.systemAndCode("https://fhir.kemkes.go.id/id/nik", nik))
.returnBundle(Bundle.class)
.execute();
if (!existing.getEntry().isEmpty()) {
String existingId = existing.getEntryFirstRep()
.getResource().getIdElement().getIdPart();
patient.setId(existingId);
log.debug("[FHIR] Updating existing Patient id={}", existingId);
return fhirClient.update()
.resource(patient).execute();
}
}
log.debug("[FHIR] Creating new Patient NIK={}", nik);
return fhirClient.create()
.resource(patient).encodedJson().execute();
}
/**
* Read by FHIR ID
*/
public Patient findById(String id) {
return fhirClient.read()
.resource(Patient.class)
.withId(id)
.execute();
}
/**
* Search by NIK
*/
public Optional<Patient> findByNik(String nik) {
Bundle bundle = fhirClient.search()
.forResource(Patient.class)
.where(Patient.IDENTIFIER.exactly()
.systemAndCode("https://fhir.kemkes.go.id/id/nik", nik))
.returnBundle(Bundle.class)
.execute();
return bundle.getEntry().stream()
.map(e -> (Patient) e.getResource())
.findFirst();
}
/**
* Search by nama + tanggal lahir (jika NIK tidak tersedia)
*/
public Bundle searchByNameAndBirthdate(String familyName, LocalDate birthdate) {
return fhirClient.search()
.forResource(Patient.class)
.where(Patient.FAMILY.matches().value(familyName))
.and(Patient.BIRTHDATE.exactly().day(birthdate.toString()))
.returnBundle(Bundle.class)
.execute();
}
private String extractNik(Patient patient) {
return patient.getIdentifier().stream()
.filter(id -> "https://fhir.kemkes.go.id/id/nik".equals(id.getSystem()))
.map(Identifier::getValue)
.findFirst().orElse(null);
}
}
EncounterService — Kunjungan Pasien
📄 fhir/EncounterService.java@Service
@RequiredArgsConstructor
@Slf4j
public class EncounterService {
private final IGenericClient fhirClient;
public MethodOutcome create(Encounter encounter) {
log.debug("[FHIR] Creating Encounter for Patient={}",
encounter.getSubject().getReference());
return fhirClient.create()
.resource(encounter)
.encodedJson()
.execute();
}
/**
* Cari semua kunjungan pasien, diurutkan dari terbaru.
*/
public Bundle findByPatientId(String patientId) {
return fhirClient.search()
.forResource(Encounter.class)
.where(Encounter.PATIENT.hasId(patientId))
.sort().descending(Encounter.DATE)
.returnBundle(Bundle.class)
.execute();
}
}
ObservationService — Hasil Lab & Vital Signs
📄 fhir/ObservationService.java@Service
@RequiredArgsConstructor
public class ObservationService {
private final IGenericClient fhirClient;
public MethodOutcome create(Observation observation) {
return fhirClient.create()
.resource(observation).encodedJson().execute();
}
/**
* Cari hasil lab pasien.
* LOINC 15074-8 = Glucose [Moles/volume] in Blood
* Gunakan kode LOINC yang sesuai untuk jenis lab yang dicari.
*/
public Bundle findLabResults(String patientId, String loincCode) {
ICriterion<?> patientCriteria = Observation.PATIENT.hasId(patientId);
ICriterion<?> codeCriteria = Observation.CODE.exactly()
.systemAndCode("http://loinc.org", loincCode);
return fhirClient.search()
.forResource(Observation.class)
.where(patientCriteria)
.and(codeCriteria)
.and(Observation.CATEGORY.exactly().code("laboratory"))
.sort().descending(Observation.DATE)
.returnBundle(Bundle.class)
.execute();
}
}
Bagian 4 — Transformer: HL7 v2 → FHIR R4
Ini adalah komponen paling kritis dalam keseluruhan sistem. Konversi harus presisi karena berkaitan dengan data klinis pasien. Setiap field HL7 yang hilang atau salah mapping bisa berdampak pada keselamatan pasien.
📄 transformer/Hl7ToFhirTransformer.java@Component
@Slf4j
public class Hl7ToFhirTransformer {
private static final String NIK_SYSTEM = "https://fhir.kemkes.go.id/id/nik";
private static final String MRN_SYSTEM = "http://rs.example.id/mrn";
private static final String LOINC = "http://loinc.org";
private static final String ICD10 = "http://hl7.org/fhir/sid/icd-10";
// ══════════════════════════════════════════════════════════
// PID Segment → FHIR Patient
// ══════════════════════════════════════════════════════════
public Patient toPatient(PID pid) throws HL7Exception {
Patient patient = new Patient();
// PID-3: Patient Identifier List (bisa multi-value)
for (int i = 0; i < pid.getPatientIdentifierListReps(); i++) {
CX cx = pid.getPatientIdentifierList(i);
String idType = cx.getIdentifierTypeCode().getValue();
String idValue = cx.getIDNumber().getValue();
if ("NIK".equalsIgnoreCase(idType)) {
patient.addIdentifier()
.setUse(Identifier.IdentifierUse.OFFICIAL)
.setSystem(NIK_SYSTEM)
.setValue(idValue);
} else if ("MR".equalsIgnoreCase(idType)) {
patient.addIdentifier()
.setUse(Identifier.IdentifierUse.USUAL)
.setSystem(MRN_SYSTEM)
.setValue(idValue);
}
}
// PID-5: Patient Name — bisa multi (official, alias, dll)
for (int i = 0; i < pid.getPatientNameReps(); i++) {
XPN xpn = pid.getPatientName(i);
HumanName name = patient.addName();
String family = getValueSafe(xpn.getFamilyName().getSurname());
String given = getValueSafe(xpn.getGivenName());
String prefix = getValueSafe(xpn.getPrefixEgDR());
if (family != null) name.setFamily(family);
if (given != null) name.addGiven(given);
if (prefix != null) name.addPrefix(prefix);
String nameTypeCode = getValueSafe(xpn.getNameTypeCode());
if ("L".equals(nameTypeCode) || nameTypeCode == null) {
name.setUse(HumanName.NameUse.OFFICIAL);
}
}
// PID-7: Date/Time of Birth
TS dob = pid.getDateTimeOfBirth();
if (dob != null && dob.getTime() != null) {
Date birthDate = dob.getTime().getValueAsDate();
if (birthDate != null) patient.setBirthDate(birthDate);
}
// PID-8: Administrative Sex — "M", "F", "O", "U"
String sex = getValueSafe(pid.getAdministrativeSex());
if (sex != null) {
patient.setGender(mapGender(sex));
}
// PID-11: Patient Address
for (int i = 0; i < pid.getPatientAddressReps(); i++) {
XAD xad = pid.getPatientAddress(i);
Address addr = patient.addAddress();
addr.setUse(Address.AddressUse.HOME);
String street = getValueSafe(xad.getStreetAddress().getStreetOrMailingAddress());
String city = getValueSafe(xad.getCity());
String zip = getValueSafe(xad.getZipOrPostalCode());
String country = getValueSafe(xad.getCountry());
if (street != null) addr.addLine(street);
if (city != null) addr.setCity(city);
if (zip != null) addr.setPostalCode(zip);
if (country != null) addr.setCountry(country);
}
// PID-13/14: Phone Numbers
for (int i = 0; i < pid.getPhoneNumberHomeReps(); i++) {
XTN phone = pid.getPhoneNumberHome(i);
String number = getValueSafe(phone.getTelephoneNumber());
if (number != null) {
patient.addTelecom()
.setSystem(ContactPoint.ContactPointSystem.PHONE)
.setUse(ContactPoint.ContactPointUse.HOME)
.setValue(number);
}
}
return patient;
}
// ══════════════════════════════════════════════════════════
// PV1 Segment → FHIR Encounter
// ══════════════════════════════════════════════════════════
public Encounter toEncounter(PV1 pv1, String patientId) throws HL7Exception {
Encounter encounter = new Encounter();
// Reference ke Patient
encounter.setSubject(new Reference("Patient/" + patientId));
// PV1-2: Patient Class — "I" inpatient, "O" outpatient, "E" emergency
String patClass = getValueSafe(pv1.getPatientClass());
if (patClass != null) {
encounter.setClass_(new Coding()
.setSystem("http://terminology.hl7.org/CodeSystem/v3-ActCode")
.setCode(mapEncounterClass(patClass))
.setDisplay(mapEncounterClassDisplay(patClass)));
}
// Status: aktif (sedang berlangsung)
encounter.setStatus(Encounter.EncounterStatus.INPROGRESS);
// PV1-3: Assigned Patient Location (ruang/poli)
if (pv1.getAssignedPatientLocationReps() > 0) {
PL location = pv1.getAssignedPatientLocation(0);
String ward = getValueSafe(location.getPointOfCare());
String desc = getValueSafe(location.getLocationDescription());
if (ward != null) {
encounter.addLocation()
.setLocation(new Reference().setDisplay(
desc != null ? desc : ward));
}
}
// PV1-7: Attending Doctor
if (pv1.getAttendingDoctorReps() > 0) {
XCN doctor = pv1.getAttendingDoctor(0);
String doctorId = getValueSafe(doctor.getIDNumber());
String lastName = getValueSafe(doctor.getFamilyName().getSurname());
if (doctorId != null) {
encounter.addParticipant()
.addType(codeableConcept(
"http://terminology.hl7.org/CodeSystem/v3-ParticipationType",
"ATND", "attender"))
.setIndividual(new Reference()
.setDisplay(lastName != null ? "dr. " + lastName : doctorId));
}
}
return encounter;
}
// ══════════════════════════════════════════════════════════
// OBX Segment → FHIR Observation
// ══════════════════════════════════════════════════════════
public Observation toObservation(OBX obx, String patientId) throws HL7Exception {
Observation obs = new Observation();
obs.setStatus(Observation.ObservationStatus.FINAL);
obs.setSubject(new Reference("Patient/" + patientId));
// OBX-3: Observation Identifier — LOINC code
CE obsId = obx.getObservationIdentifier();
String loincCode = getValueSafe(obsId.getIdentifier());
String loincDesc = getValueSafe(obsId.getText());
if (loincCode != null) {
obs.setCode(codeableConcept(LOINC, loincCode, loincDesc));
}
// OBX-5: Observation Value (bisa NM, ST, CWE, dll)
String valueType = getValueSafe(obx.getValueType());
Varies[] values = obx.getObservationValue();
if (values.length > 0) {
Type value = values[0].getData();
if ("NM".equals(valueType) && value instanceof NM) {
// Numeric value — mis: glukosa = 95.0 mg/dL
String numStr = ((NM) value).getValue();
if (numStr != null) {
Quantity q = new Quantity();
q.setValue(new BigDecimal(numStr));
// OBX-6: Unit (UCUM unit, mis: "mg/dL")
CE unit = obx.getUnits();
String unitCode = getValueSafe(unit.getIdentifier());
if (unitCode != null) {
q.setUnit(unitCode)
.setSystem("http://unitsofmeasure.org")
.setCode(unitCode);
}
obs.setValue(q);
}
} else if ("ST".equals(valueType) && value instanceof ST) {
// String value
obs.setValue(new StringType(((ST) value).getValue()));
} else if ("CWE".equals(valueType) && value instanceof CWE) {
// Coded value — mis: hasil kultur, golongan darah
CWE coded = (CWE) value;
obs.setValue(codeableConcept(
getValueSafe(coded.getNameOfCodingSystem()),
getValueSafe(coded.getIdentifier()),
getValueSafe(coded.getText())));
}
}
// OBX-14: Date/Time of Observation
TS observationTime = obx.getDateTimeOfTheObservation();
if (observationTime != null && observationTime.getTime() != null) {
Date obsDate = observationTime.getTime().getValueAsDate();
if (obsDate != null) obs.setEffective(new DateTimeType(obsDate));
}
// Kategori: laboratory (untuk OBX dari ORU^R01)
obs.addCategory(codeableConcept(
"http://terminology.hl7.org/CodeSystem/observation-category",
"laboratory", "Laboratory"));
return obs;
}
// ── Helper Methods ────────────────────────────────────────────
public String extractPatientId(PID pid) throws HL7Exception {
// Coba ambil FHIR Patient ID dari PID-3 extension, atau gunakan MRN
for (int i = 0; i < pid.getPatientIdentifierListReps(); i++) {
CX cx = pid.getPatientIdentifierList(i);
if ("FHIR".equalsIgnoreCase(cx.getIdentifierTypeCode().getValue())) {
return cx.getIDNumber().getValue();
}
}
return pid.getPatientIdentifierList(0).getIDNumber().getValue();
}
private Enumerations.AdministrativeGender mapGender(String hl7Sex) {
return switch (hl7Sex.toUpperCase()) {
case "M" -> Enumerations.AdministrativeGender.MALE;
case "F" -> Enumerations.AdministrativeGender.FEMALE;
case "O" -> Enumerations.AdministrativeGender.OTHER;
default -> Enumerations.AdministrativeGender.UNKNOWN;
};
}
private String mapEncounterClass(String hl7Class) {
return switch (hl7Class.toUpperCase()) {
case "I" -> "IMP"; // inpatient
case "O" -> "AMB"; // ambulatory / outpatient
case "E" -> "EMER"; // emergency
default -> "AMB";
};
}
private String mapEncounterClassDisplay(String hl7Class) {
return switch (hl7Class.toUpperCase()) {
case "I" -> "inpatient encounter";
case "O" -> "ambulatory";
case "E" -> "emergency";
default -> "ambulatory";
};
}
private CodeableConcept codeableConcept(String system, String code, String display) {
CodeableConcept cc = new CodeableConcept();
Coding coding = cc.addCoding().setSystem(system).setCode(code);
if (display != null) coding.setDisplay(display);
return cc;
}
private String getValueSafe(Primitive p) {
if (p == null) return null;
String val = p.getValue();
return (val == null || val.isBlank()) ? null : val.trim();
}
private String getValueSafe(String val) {
return (val == null || val.isBlank()) ? null : val.trim();
}
}
Bagian 5 — Integrasi SATUSEHAT Sandbox
SATUSEHAT menggunakan OAuth 2.0 Client Credentials flow. Token berlaku 3600 detik (1 jam). Kita perlu token manager yang menyimpan token dan auto-refresh sebelum expired.
SatuSehatAuthService — Token Manager
📄 satusehat/SatuSehatAuthService.java@Service
@Slf4j
public class SatuSehatAuthService {
@Value("${satusehat.auth-url}")
private String authUrl;
@Value("${satusehat.client-id}")
private String clientId;
@Value("${satusehat.client-secret}")
private String clientSecret;
private final WebClient webClient;
// In-memory token cache
private volatile String cachedToken;
private volatile Instant tokenExpiry = Instant.MIN;
public SatuSehatAuthService(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.build();
}
/**
* Ambil token yang valid. Otomatis refresh jika sudah/hampir expired.
* Thread-safe dengan double-checked locking.
*/
public synchronized String getValidToken() {
// Refresh jika sisa waktu < 5 menit
if (cachedToken == null || Instant.now().isAfter(tokenExpiry.minusSeconds(300))) {
refreshToken();
}
return cachedToken;
}
private void refreshToken() {
log.info("[SATUSEHAT] Refreshing OAuth2 token...");
try {
// SATUSEHAT menggunakan form-encoded body, bukan Authorization header
Map<String, Object> response = webClient.post()
.uri(authUrl + "?grant_type=client_credentials")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.bodyValue("client_id=" + clientId + "&client_secret=" + clientSecret)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.timeout(Duration.ofSeconds(15))
.block();
if (response == null || !response.containsKey("access_token")) {
throw new IllegalStateException("Empty token response from SATUSEHAT");
}
cachedToken = (String) response.get("access_token");
int expiresIn = (Integer) response.getOrDefault("expires_in", 3600);
tokenExpiry = Instant.now().plusSeconds(expiresIn);
log.info("[SATUSEHAT] Token refreshed, expires in {} seconds", expiresIn);
} catch (Exception e) {
log.error("[SATUSEHAT] Token refresh failed: {}", e.getMessage());
throw new RuntimeException("Cannot get SATUSEHAT access token", e);
}
}
}
SatuSehatClient — Kirim Resource ke SATUSEHAT
📄 satusehat/SatuSehatClient.java@Service
@RequiredArgsConstructor
@Slf4j
public class SatuSehatClient {
private final SatuSehatAuthService authService;
private final FhirContext fhirContext;
private final WebClient.Builder webClientBuilder;
@Value("${satusehat.base-url}")
private String baseUrl;
@Value("${satusehat.enabled:true}")
private boolean enabled;
/**
* Kirim Patient ke SATUSEHAT.
* Async — tidak memblokir proses ACK HL7.
*/
@Async
public CompletableFuture<String> sendPatientAsync(Patient patient) {
if (!enabled) return CompletableFuture.completedFuture("disabled");
return CompletableFuture.supplyAsync(() -> sendResource("Patient", patient));
}
@Async
public CompletableFuture<String> sendObservationAsync(Observation obs) {
if (!enabled) return CompletableFuture.completedFuture("disabled");
return CompletableFuture.supplyAsync(() -> sendResource("Observation", obs));
}
@Async
public CompletableFuture<String> sendEncounterAsync(Encounter encounter) {
if (!enabled) return CompletableFuture.completedFuture("disabled");
return CompletableFuture.supplyAsync(() -> sendResource("Encounter", encounter));
}
/**
* Serialize FHIR resource ke JSON → POST ke SATUSEHAT dengan retry.
*/
private String sendResource(String resourceType, IBaseResource resource) {
int maxRetries = 3;
int attempt = 0;
while (attempt < maxRetries) {
attempt++;
try {
String token = authService.getValidToken();
String json = serializeToJson(resource);
String result = webClientBuilder.build()
.post()
.uri(baseUrl + "/" + resourceType)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.header(HttpHeaders.CONTENT_TYPE, "application/fhir+json")
.bodyValue(json)
.retrieve()
.onStatus(status -> status.is4xxClientError(), response ->
response.bodyToMono(String.class)
.flatMap(body -> Mono.error(
new SatuSehatClientException("4xx error: " + body))))
.onStatus(status -> status.is5xxServerError(), response ->
response.bodyToMono(String.class)
.flatMap(body -> Mono.error(
new SatuSehatServerException("5xx error: " + body))))
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(30))
.block();
log.info("[SATUSEHAT] ✓ {} sent successfully", resourceType);
return result;
} catch (SatuSehatClientException e) {
// 4xx: jangan retry, ini kesalahan data kita
log.error("[SATUSEHAT] Client error sending {}: {}", resourceType, e.getMessage());
throw e;
} catch (Exception e) {
log.warn("[SATUSEHAT] Attempt {}/{} failed for {}: {}",
attempt, maxRetries, resourceType, e.getMessage());
if (attempt == maxRetries) {
log.error("[SATUSEHAT] All retries exhausted for {}", resourceType);
throw new RuntimeException("SATUSEHAT send failed after retries", e);
}
// Exponential backoff: 1s, 2s, 4s
try { Thread.sleep(1000L * (long) Math.pow(2, attempt - 1)); }
catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}
}
throw new IllegalStateException("Unreachable");
}
private String serializeToJson(IBaseResource resource) {
IParser parser = fhirContext.newJsonParser();
parser.setPrettyPrint(false);
return parser.encodeResourceToString(resource);
}
}
Bagian 6 — Unit Test untuk Transformer
Test transformer adalah yang paling krusial karena kesalahan mapping di sini langsung berdampak ke data klinis.
📄 src/test/java/.../transformer/Hl7ToFhirTransformerTest.java@SpringBootTest(classes = {Hl7ToFhirTransformer.class})
class Hl7ToFhirTransformerTest {
@Autowired
private Hl7ToFhirTransformer transformer;
private Parser parser;
@BeforeEach
void setUp() {
this.parser = new PipeParser();
}
@Test
@DisplayName("PID segment dengan NIK dan MRN → FHIR Patient dengan 2 identifier")
void testPidToPatient_withNikAndMrn() throws Exception {
// GIVEN: pesan ADT^A04 dengan PID lengkap
String hl7msg = "MSH|^~\\&|SIMRS|RS_TEST|GATEWAY|PUSAT|20250622||ADT^A04^ADT_A01|MSG001|P|2.5.1\r"
+ "PID|1||12345678^^^RS_TEST^MR~3273011501850001^^^Dukcapil^NIK||Ibrahim^Mohamad Yusuf||19850101|M|||Jl. Merdeka^^Bandung^JB^40111^ID||(022)1234567";
ADT_A01 adt = (ADT_A01) parser.parse(hl7msg);
// WHEN
Patient patient = transformer.toPatient(adt.getPID());
// THEN
assertThat(patient.getIdentifier()).hasSize(2);
// NIK identifier
assertThat(patient.getIdentifier())
.anyMatch(id -> "https://fhir.kemkes.go.id/id/nik".equals(id.getSystem())
&& "3273011501850001".equals(id.getValue()));
// MRN identifier
assertThat(patient.getIdentifier())
.anyMatch(id -> id.getSystem().contains("mrn")
&& "12345678".equals(id.getValue()));
// Nama
assertThat(patient.getNameFirstRep().getFamily()).isEqualTo("Ibrahim");
assertThat(patient.getNameFirstRep().getGiven()).hasSize(1);
assertThat(patient.getNameFirstRep().getGivenAsSingleString()).isEqualTo("Mohamad Yusuf");
// Gender
assertThat(patient.getGender()).isEqualTo(Enumerations.AdministrativeGender.MALE);
// Tanggal lahir
assertThat(patient.getBirthDateElement().getValueAsString()).isEqualTo("1985-01-01");
}
@Test
@DisplayName("OBX numeric → FHIR Observation dengan nilai dan satuan")
void testObxToObservation_numericGlucose() throws Exception {
// GIVEN: OBX untuk hasil glukosa darah
String hl7msg = "MSH|^~\\&|LIS|LAB|SIMRS|RS|20250622||ORU^R01|LAB001|P|2.5.1\r"
+ "PID|1||12345678\r"
+ "OBR|1|LAB-001|LAB-001|15074-8^Glucose^LN\r"
+ "OBX|1|NM|15074-8^Glucose [Moles/vol] in Blood^LN||6.3|mmol/L|3.9-6.1||||F|||20250622150000";
ORU_R01 oru = (ORU_R01) parser.parse(hl7msg);
OBX obx = oru.getPATIENT_RESULT().getORDER_OBSERVATION().getOBSERVATION().getOBX();
// WHEN
Observation obs = transformer.toObservation(obx, "patient-123");
// THEN
assertThat(obs.getStatus()).isEqualTo(Observation.ObservationStatus.FINAL);
assertThat(obs.getCode().getCodingFirstRep().getSystem()).isEqualTo("http://loinc.org");
assertThat(obs.getCode().getCodingFirstRep().getCode()).isEqualTo("15074-8");
// Value harus Quantity
assertThat(obs.getValue()).isInstanceOf(Quantity.class);
Quantity qty = (Quantity) obs.getValue();
assertThat(qty.getValue()).isEqualByComparingTo(new BigDecimal("6.3"));
assertThat(qty.getUnit()).isEqualTo("mmol/L");
assertThat(qty.getSystem()).isEqualTo("http://unitsofmeasure.org");
}
}
Bagian 7 — Docker Compose: Full Stack
Satu perintah docker compose up -d untuk menyalakan seluruh infrastruktur:
📄 docker-compose.ymlversion: '3.8'
services:
# ── PostgreSQL — database untuk HAPI FHIR Server ──────────────
postgres:
image: postgres:16-alpine
container_name: hc-postgres
environment:
POSTGRES_DB: fhirdb
POSTGRES_USER: fhiruser
POSTGRES_PASSWORD: fhirpass
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fhiruser -d fhirdb"]
interval: 10s
timeout: 5s
retries: 5
# ── HAPI FHIR JPA Server — FHIR server lokal ─────────────────
hapi-fhir:
image: hapiproject/hapi:latest
container_name: hc-fhir-server
ports:
- "8090:8080"
environment:
hapi.fhir.fhir_version: R4
hapi.fhir.allow_multiple_delete: "true"
spring.datasource.url: jdbc:postgresql://postgres:5432/fhirdb
spring.datasource.username: fhiruser
spring.datasource.password: fhirpass
spring.datasource.driverClassName: org.postgresql.Driver
spring.jpa.properties.hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgres94Dialect
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/fhir/metadata"]
interval: 30s
timeout: 10s
retries: 10
start_period: 60s
# ── PostgreSQL untuk aplikasi kita (terpisah dari FHIR) ────────
app-postgres:
image: postgres:16-alpine
container_name: hc-app-postgres
environment:
POSTGRES_DB: healthdb
POSTGRES_USER: healthuser
POSTGRES_PASSWORD: healthpass
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U healthuser -d healthdb"]
interval: 10s
retries: 5
# ── HL7 Sender Simulator — untuk test MLLP ───────────────────
# Mengirim pesan ADT^A04 sampel setiap 30 detik
hl7-simulator:
image: alpine:3.19
container_name: hc-hl7-simulator
depends_on:
- hapi-fhir # pastikan app sudah ready dulu
volumes:
- ./scripts/hl7-simulator.sh:/simulator.sh
command: sh /simulator.sh
restart: unless-stopped
# ── Aplikasi kita ─────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
container_name: hc-integration-service
ports:
- "8080:8080" # Spring Boot HTTP
- "2575:2575" # MLLP listener
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://app-postgres:5432/healthdb
SPRING_DATASOURCE_USERNAME: healthuser
SPRING_DATASOURCE_PASSWORD: healthpass
FHIR_SERVER_BASE_URL: http://hapi-fhir:8080/fhir
SATUSEHAT_CLIENT_ID: ${SATUSEHAT_CLIENT_ID}
SATUSEHAT_CLIENT_SECRET: ${SATUSEHAT_CLIENT_SECRET}
SATUSEHAT_ORGANIZATION_ID: ${SATUSEHAT_ORGANIZATION_ID}
depends_on:
hapi-fhir:
condition: service_healthy
app-postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
retries: 5
volumes:
postgres_data:
Dockerfile untuk Aplikasi Kita
📄 Dockerfile# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon # cache layer
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test
# Stage 2: Runtime (image lebih kecil)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
# Port HTTP + MLLP
EXPOSE 8080 2575
ENTRYPOINT ["java", "-jar", "-Djava.security.egd=file:/dev/./urandom", "app.jar"]
HL7 Simulator Script
Script sederhana untuk mengirim pesan ADT^A04 via MLLP ke aplikasi kita, menggunakan netcat:
📄 scripts/hl7-simulator.sh#!/bin/sh
# Simulator: kirim ADT^A04 ke MLLP listener setiap 30 detik
# Gunakan di Docker — tidak butuh tool khusus
APP_HOST="app"
APP_PORT="2575"
MLLP_START="$(printf '\x0b')" # 0x0B VT
MLLP_END="$(printf '\x1c\x0d')" # 0x1C FS + 0x0D CR
while true; do
TIMESTAMP=$(date +%Y%m%d%H%M%S)
MSG_ID="MSG-${TIMESTAMP}"
# Bangun pesan HL7 v2 ADT^A04
HL7_MSG="MSH|^~\&|SIMRS_TEST|RS_HARAPAN|HIS_GATEWAY|PUSAT|${TIMESTAMP}||ADT^A04^ADT_A01|${MSG_ID}|P|2.5.1
PID|1||98765432^^^RS_HARAPAN^MR~3273019901010001^^^Dukcapil^NIK||Santoso^Budi||19901231|M|||Jl. Sudirman No. 99^^Jakarta^JK^10220^ID||(021)87654321
PV1|1|O|POLI-UMUM^Poliklinik Umum^RS_HARAPAN||||dr.ANDIKA^Andika^Pratama^^^dr.|||MED
IN1|1|BPJS|BPJS|BPJS Kesehatan||||0001998765432"
# Kirim via netcat (tersedia di Alpine)
printf "%s%s%s" "$MLLP_START" "$HL7_MSG" "$MLLP_END" | nc -w 5 $APP_HOST $APP_PORT
echo "[SIM] Sent ADT^A04 ${MSG_ID} at ${TIMESTAMP}"
sleep 30
done
Bagian 8 — Menjalankan & Verifikasi End-to-End
Langkah Menjalankan
-
1Clone dan konfigurasi credential Isi SATUSEHAT_CLIENT_ID, SATUSEHAT_CLIENT_SECRET, dan SATUSEHAT_ORGANIZATION_ID di file .env atau environment variable. Credential didapat dari portal developer SATUSEHAT.
-
2Jalankan infrastruktur docker compose up -d postgres app-postgres hapi-fhir — tunggu hingga HAPI FHIR server healthy (~60 detik pertama kali). Verifikasi: akses http://localhost:8090/fhir/metadata.
-
3Jalankan aplikasi Spring Boot ./gradlew bootRun --args='--spring.profiles.active=dev' untuk mode development dengan H2. Atau docker compose up -d app untuk full Docker.
-
4Kirim test HL7 message Jalankan docker compose up hl7-simulator atau gunakan tool seperti HAPI TestPanel untuk mengirim pesan ADT^A04 ke port 2575.
-
5Verifikasi di FHIR server lokal Buka http://localhost:8090/fhir/Patient?_format=json — Anda harus melihat Patient resource yang baru dibuat dari konversi HL7 v2.
-
6Verifikasi di SATUSEHAT Login ke portal SATUSEHAT sandbox dan cek apakah data masuk. Atau cek log aplikasi untuk konfirmasi response HTTP 200/201 dari SATUSEHAT.
Query untuk Verifikasi Data
🔍 Verifikasi FHIR resources di server lokal-- Lihat semua Patient yang baru masuk
GET http://localhost:8090/fhir/Patient?_count=10&_sort=-_lastUpdated
-- Cari patient berdasarkan NIK
GET http://localhost:8090/fhir/Patient?identifier=https://fhir.kemkes.go.id/id/nik|3273019901010001
-- Lihat Encounter yang terkait patient
GET http://localhost:8090/fhir/Encounter?patient=[PATIENT_ID]
-- Lihat semua Observation lab pasien
GET http://localhost:8090/fhir/Observation?patient=[PATIENT_ID]&category=laboratory
-- Health check aplikasi kita
GET http://localhost:8080/actuator/health
Kesimpulan & Langkah Selanjutnya
Kita sudah membangun sistem integrasi kesehatan yang lengkap: dari MLLP listener yang menerima HL7 v2, transformer yang mengkonversinya ke FHIR R4, service layer untuk CRUD ke FHIR server lokal, sampai pengiriman async ke SATUSEHAT dengan OAuth2 dan retry logic.
Ini adalah fondasi yang cukup solid untuk project integrasi SIMRS nyata. Beberapa hal yang perlu diperhatikan sebelum masuk produksi:
| Aspek | Catatan untuk Produksi |
|---|---|
| Secret Management | Jangan simpan client_secret di application.yml. Gunakan HashiCorp Vault, AWS Secrets Manager, atau Spring Cloud Config dengan enkripsi. |
| Dead Letter Queue | Pesan HL7 yang gagal diproses harus masuk DLQ (Kafka / RabbitMQ) untuk reprocessing manual, bukan sekadar dilog. |
| Idempotency | MLLP bisa mengirim ulang pesan yang sama (retransmit). Implementasikan idempotency check berdasarkan MSH-10 (Message Control ID). |
| FHIR Validation | Aktifkan validator SATUSEHAT profile sebelum kirim ke produksi. Profil FHIR Kemenkes lebih ketat dari spesifikasi R4 standar. |
| Audit Trail | Data klinis wajib punya audit log lengkap (siapa mengubah apa, kapan). Pertimbangkan FHIR AuditEvent resource. |
| Enkripsi Data | Data pasien (PII + PHI) harus dienkripsi at-rest (kolom DB) dan in-transit (TLS 1.2+). Ini bukan opsional di healthcare. |
Di artikel berikutnya saya akan membahas lebih dalam tentang FHIR Subscription (notifikasi real-time ketika resource berubah) dan implementasi SMART on FHIR untuk autentikasi pasien mengakses data rekam medis mereka sendiri.
💬 Feedback & Diskusi
Apakah ada bagian yang perlu saya jelaskan lebih detail? Atau ada bagian yang error saat Anda coba implementasi? Tinggalkan komentar di bawah — saya aktif membalas.
GitHub repository dengan kode lengkap (termasuk unit test dan integration test) akan saya share di artikel selanjutnya. Subscribe agar tidak ketinggalan.
Comments
Post a Comment