Membangun Sistem HL7 v2 & FHIR dengan Spring Boot dari Nol hingga SATUSEHAT
🏥 Hands-On Series — HealthTech untuk Java Developer
Membangun Sistem HL7 v2 & FHIR dengan Spring Boot
dari Nol hingga SATUSEHAT
Panduan praktis end-to-end di MacBook Pro M4 Pro: setup project, parse HL7 v2 via MLLP, CRUD FHIR R4, konversi HL7→FHIR, integrasi SATUSEHAT sandbox, Docker Compose, dan troubleshooting spesifik Apple Silicon.
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.
Artikel ini ditulis dan diuji langsung di MacBook Pro M4 Pro (14-core, 24 GB RAM) — semua perintah, konfigurasi Docker, dan catatan performa mengacu pada lingkungan Apple Silicon tersebut. Jika Anda menggunakan Mac lain dengan chip M-series, langkah-langkahnya identik.
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.
Prerequisites & Setup di MacBook Pro M4 Pro
Artikel ini diuji langsung pada MacBook Pro M4 Pro (14-core, 24 GB RAM, macOS Sequoia). Semua tool di bawah harus versi native Apple Silicon (arm64) — jangan install versi x86/Intel karena akan lambat dan berpotensi konflik.
1. Install Homebrew (jika belum ada)
📟 Terminal# Install Homebrew (package manager untuk macOS)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Setelah install, tambahkan ke PATH (untuk M-chip, Homebrew ada di /opt/homebrew)
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zshrc
source ~/.zshrc
# Verifikasi Homebrew berjalan di arm64 (bukan Rosetta)
brew config | grep -E "Rosetta 2|CPU"
# Output harus: CPU: 14-core arm64 Apple M4 Pro
# Rosetta 2: false ← pastikan ini false!
2. Install JDK 21 (Eclipse Temurin — Native ARM64)
📟 Terminal# Install Eclipse Temurin 21 via Homebrew Cask
brew install --cask temurin@21
# Verifikasi instalasi
java -version
# Harus menampilkan:
# openjdk version "21.x.x" ...
# OpenJDK 64-Bit Server VM (build 21.x.x+..., mixed mode, sharing)
# ← perhatikan TIDAK ada tulisan "Rosetta" atau "i386"
# Verifikasi arsitektur — WAJIB aarch64!
java -XshowSettings:all -version 2>&1 | grep "os.arch"
# Output: os.arch = aarch64
# Set JAVA_HOME di .zshrc
echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 21)' >> ~/.zshrc
source ~/.zshrc
echo $JAVA_HOME
# Contoh output: /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
3. Install Docker Desktop for Apple Silicon
📟 Terminal# Install Docker Desktop via Homebrew Cask (versi Apple Silicon otomatis)
brew install --cask docker
# Buka Docker Desktop dari Applications, tunggu hingga fully started
# Lalu verifikasi platform
docker info | grep -E "Architecture|OS"
# Output: Architecture: aarch64
# Sangat direkomendasikan: alokasikan lebih banyak resource untuk M4 Pro 24GB
# Docker Desktop → Settings → Resources:
# Memory: 8 GB (atau lebih, karena HAPI FHIR butuh ~2GB sendiri)
# CPUs: 6
# Disk: 64 GB minimum
4. Install IntelliJ IDEA (Apple Silicon Native)
📟 Terminal# Via Homebrew (otomatis pilih Apple Silicon build)
brew install --cask intellij-idea
# Atau download manual dari jetbrains.com → pilih ".dmg (Apple Silicon)"
# Jangan pilih yang "Intel" atau "Universal" jika ingin performa optimal di M4 Pro
# Verifikasi: IntelliJ → About → Architecture harus menampilkan "AArch64"
5. Konfigurasi IntelliJ untuk JDK 21 ARM64
Buka IntelliJ → Settings → Build, Execution, Deployment → Build Tools → Gradle → pastikan Gradle JVM menunjuk ke JAVA_HOME (Temurin 21 aarch64), bukan JVM bawaan macOS.
| Tool | Versi | Cara Install di M4 Pro | Verifikasi |
|---|---|---|---|
| JDK 21 | 21.x (LTS) | brew install --cask temurin@21 | java -version → aarch64 |
| Docker Desktop | 4.30+ | brew install --cask docker | docker info | grep Architecture |
| IntelliJ IDEA | 2024.x | brew install --cask intellij-idea | About → AArch64 |
| Git | 2.x | Sudah ada, atau brew install git | git --version |
| Akun SATUSEHAT | — | Daftar di platform.satusehat.kemkes.go.id | Login berhasil |
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. Di M4 Pro, Gradle first build akan men-download ~500MB dependencies — siapkan koneksi stabil. Build selanjutnya jauh lebih cepat karena Gradle cache. 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}"
}
org.gradle.java.installations.paths=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
Inisialisasi Project & Gradle Wrapper
📟 Terminal — buat project dari nol# Buat direktori project
mkdir healthcare-integration-service && cd healthcare-integration-service
# Inisialisasi Gradle Wrapper (direkomendasikan daripada install Gradle global)
gradle wrapper --gradle-version 8.10
# Verifikasi Gradle berjalan dengan JDK yang benar
./gradlew --version
# Harus tampil: JVM: 21.x.x (Eclipse Adoptium 21.x.x+...)
# Dan TIDAK ada peringatan tentang Rosetta
# Buat struktur direktori
mkdir -p src/main/java/id/yusuf/healthcareis/{config,hl7,fhir,transformer,satusehat}
mkdir -p src/main/resources
mkdir -p src/test/java/id/yusuf/healthcareis/transformer
mkdir -p scripts
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 di M4 Pro
Satu perintah docker compose up -d untuk menyalakan seluruh infrastruktur. Semua image di bawah sudah multi-arch dan berjalan native di Apple Silicon tanpa emulasi Rosetta:
📄 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
Image eclipse-temurin:21-jdk-alpine sudah multi-arch. Saat build di M4 Pro, Docker akan otomatis pull layer ARM64, sehingga build berjalan native tanpa emulasi:
📄 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 di M4 Pro
Cek Port Sebelum Mulai
macOS Sequoia punya beberapa layanan yang bisa konflik. Jalankan ini dulu sebelum docker compose up:
📟 Terminal — cek konflik port# Cek apakah port yang kita butuhkan sudah dipakai
lsof -i :2575 # MLLP — harusnya kosong
lsof -i :5432 # PostgreSQL — mungkin ada brew postgresql
lsof -i :5433 # PostgreSQL app — harusnya kosong
lsof -i :8080 # Spring Boot — pastikan tidak ada app lain
lsof -i :8090 # HAPI FHIR — harusnya kosong
# Jika port 5432 dipakai Homebrew PostgreSQL, stop dulu:
brew services stop postgresql@16 # atau @14, sesuai versi yang terinstall
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 — di M4 Pro, HAPI FHIR startup membutuhkan ~45–60 detik (lebih cepat dari Intel). Tunggu hingga healthy sebelum lanjut. Verifikasi: curl http://localhost:8090/fhir/metadata | head -5
-
3Jalankan aplikasi Spring Boot (lokal) ./gradlew bootRun --args='--spring.profiles.active=dev' — mode dev pakai H2 in-memory, tidak butuh PostgreSQL running. Gradle build pertama kali ~3–5 menit (download dependencies). Build selanjutnya <30 detik di M4 Pro.
-
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
Troubleshooting — Masalah Umum di MacBook Pro M4 Pro
Bagian ini mengumpulkan error yang paling sering muncul saat mengikuti tutorial ini di Apple Silicon, beserta cara mengatasinya.
🔴 Error 1: Java berjalan via Rosetta (bukan native arm64)
📟 Diagnosis & Fix# Cek Java mana yang aktif
which java # harusnya /Library/Java/JavaVirtualMachines/... atau /opt/homebrew/...
file $(which java) # harus tampil "Mach-O 64-bit executable arm64"
# Jika masih x86_64, kemungkinan ada Java lama di PATH
# Fix: set JAVA_HOME ke Temurin yang baru
export JAVA_HOME=$(/usr/libexec/java_home -v 21 -a arm64)
export PATH=$JAVA_HOME/bin:$PATH
# Tambahkan ke ~/.zshrc agar permanen
echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 21 -a arm64)' >> ~/.zshrc
echo 'export PATH=$JAVA_HOME/bin:$PATH' >> ~/.zshrc
source ~/.zshrc
# Verifikasi ulang
java -XshowSettings:all -version 2>&1 | grep os.arch
# Output: os.arch = aarch64 ✓
🔴 Error 2: Port 5432 sudah dipakai (Homebrew PostgreSQL)
📟 Diagnosis & Fix# Cek siapa yang pakai port 5432
lsof -i :5432
# Jika muncul "postgres" dari Homebrew, stop dulu
# Option A: Stop PostgreSQL Homebrew
brew services stop postgresql@16 # ganti 16 dengan versi yang terinstall
brew services stop postgresql@14 # coba ini juga jika yang atas tidak berhasil
# Option B: Ubah port di docker-compose.yml (jika ingin keduanya jalan bersamaan)
# Ganti "5432:5432" menjadi "5434:5432" di service postgres
# Dan update SPRING_DATASOURCE_URL di service app menjadi port 5434
# Verifikasi port sudah bebas
lsof -i :5432 # harusnya tidak ada output
🔴 Error 3: Docker image "no matching manifest for linux/arm64"
📟 Diagnosis & Fix# Cek apakah image punya manifest arm64
docker manifest inspect hapiproject/hapi:latest | grep -A2 "platform"
# Jika image TIDAK punya arm64 manifest (image lama/legacy):
# Force emulasi x86_64 dengan platform flag
docker pull --platform linux/amd64 nama-image:tag
# Di docker-compose.yml, tambahkan platform ke service yang bermasalah:
# platform: linux/amd64
# Contoh:
# services:
# some-x86-only-image:
# image: some-legacy:latest
# platform: linux/amd64 ← tambahkan ini
# Catatan: hapiproject/hapi dan postgres:alpine sudah multi-arch,
# jadi error ini biasanya hanya di image pihak ketiga yang jarang diupdate
🔴 Error 4: macOS Firewall block koneksi MLLP (port 2575)
📟 Fix# Saat dialog muncul, klik "Allow"
# Jika sudah terlanjur klik "Deny", perbaiki di:
# System Settings → Network → Firewall → Options...
# Cari "java" di daftar dan pastikan Allow incoming connections = ON
# Untuk testing lokal (tanpa Docker), juga bisa izinkan via Terminal:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add $JAVA_HOME/bin/java
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --unblock $JAVA_HOME/bin/java
🔴 Error 5: HAPI FHIR startup OOM (Out of Memory)
📟 Fix — tambahkan JVM memory limit ke docker-compose.yml# Di service hapi-fhir, tambahkan environment variable:
hapi-fhir:
image: hapiproject/hapi:latest
environment:
# Tambahkan baris ini:
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx2g" # min 512MB, max 2GB
# ... environment lainnya
# Dan pastikan Docker Desktop punya cukup memory (Settings → Resources → Memory ≥ 8GB)
# Jika di M4 Pro 24GB, aman set Docker memory ke 12GB
# Monitor memory penggunaan container:
docker stats hc-fhir-server
🔴 Error 6: Gradle build gagal — "Could not find tools.jar"
📟 Fix# Pastikan JAVA_HOME menunjuk ke JDK (bukan JRE)
# Temurin 21 yang diinstall via Homebrew selalu JDK, jadi ini tidak harusnya terjadi
# kecuali ada JRE lain yang di-set
# Verifikasi
echo $JAVA_HOME
ls $JAVA_HOME/bin/javac # harus ada (JDK punya javac, JRE tidak)
# Fix: pastikan gradle.properties di project menunjuk ke JDK yang benar
cat ~/.gradle/gradle.properties
# Jika tidak ada file ini, buat:
echo "org.gradle.java.home=$(dirname $(dirname $(readlink -f $(which java))))" >> ~/.gradle/gradle.properties
🔴 Error 7: MLLP connection refused dari simulator
📟 Diagnosis & Fix# Cek apakah aplikasi sudah listen di port 2575
lsof -i :2575
# Harus ada "java" listening. Jika tidak ada, aplikasi belum start atau Camel route error
# Cek log aplikasi Spring Boot untuk error di route hl7-mllp-inbound:
./gradlew bootRun 2>&1 | grep -E "MLLP|2575|ERROR|mllp"
# Jika Camel route gagal start, cek apakah dependency camel-mllp-starter sudah ada di build.gradle
# Juga pastikan mllp.port di application.yml sudah benar
# Test koneksi manual dari terminal:
nc -zv localhost 2575
# Output "Connection to localhost port 2575 [tcp/*] succeeded" berarti port terbuka
# Jika pakai Docker: pastikan port 2575 di-expose di service app:
# ports:
# - "2575:2575" ← wajib ada
🔴 Error 8: SATUSEHAT OAuth2 token gagal (401 / SSL error)
📟 Diagnosis & Fix# Test koneksi SATUSEHAT dari terminal (tanpa aplikasi)
curl -v -X POST \
"https://api-satusehat.kemkes.go.id/oauth2/v1/accesstoken?grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET"
# Jika 401: cek client_id dan client_secret di application.yml
# Pastikan tidak ada spasi atau newline di value-nya
# Jika SSL error (PKIX): macOS punya root CA yang up-to-date, biasanya tidak terjadi
# Tapi jika terjadi, tambahkan JVM flag ke bootRun:
./gradlew bootRun -Djavax.net.ssl.trustStore=$JAVA_HOME/lib/security/cacerts \
-Djavax.net.ssl.trustStorePassword=changeit
# Jika di belakang corporate proxy:
./gradlew bootRun -Dhttps.proxyHost=proxy.company.com -Dhttps.proxyPort=8080
# Verifikasi sandbox SATUSEHAT bisa diakses:
curl -I https://api-satusehat.kemkes.go.id/fhir-r4/v1/metadata
# Harus return HTTP 200 atau 401 (bukan timeout atau SSL error)
🔴 Error 9: HAPI FHIR "422 Unprocessable Entity" saat POST resource
📟 Diagnosis# Baca body response 422 dengan detail — ini berisi OperationOutcome FHIR
# yang menjelaskan field mana yang salah. Contoh response:
{
"resourceType": "OperationOutcome",
"issue": [{
"severity": "error",
"diagnostics": "Patient.identifier: minimum required = 1, but only found 0"
}]
}
# Penyebab umum di SATUSEHAT:
# 1. Patient tanpa NIK identifier → wajib ada identifier dengan system NIK
# 2. Encounter dikirim sebelum Patient-nya ada → urutan: Patient dulu baru Encounter
# 3. reference format salah → harus "Patient/[id]", bukan hanya "[id]"
# 4. Status Encounter salah → SATUSEHAT hanya terima "arrived", "in-progress", "finished"
# 5. Profil SATUSEHAT lebih ketat → aktifkan logging response penuh:
# Tambahkan di SatuSehatClient untuk log response body saat error:
.onStatus(status -> status.is4xxClientError(), response ->
response.bodyToMono(String.class)
.doOnNext(body -> log.error("[SATUSEHAT] 4xx Response body: {}", body))
.flatMap(body -> Mono.error(new SatuSehatClientException(body))))
🔴 Error 10: IntelliJ tidak mengenali class HAPI FHIR (merah semua)
📟 Fix — Refresh Gradle di IntelliJ# Option A: Via IntelliJ UI
# Klik kanan pada build.gradle → "Link Gradle Project" (jika belum linked)
# Atau: klik tombol "Reload All Gradle Projects" di panel Gradle (ikon elephant + refresh)
# Option B: Via Terminal — paksa re-import
./gradlew --refresh-dependencies build
# Lalu di IntelliJ: File → Invalidate Caches → Invalidate and Restart
# Option C: Jika masih bermasalah setelah restart
rm -rf ~/.gradle/caches/modules-2/files-2.1/ca.uhn.hapi.fhir/
./gradlew build
# Ini akan re-download semua HAPI FHIR artifacts (~200MB)
🟡 Peringatan: AirPlay Receiver & Port 7000/5000 (bukan masalah, tapi sering ditanyakan)
Kesimpulan & Langkah Selanjutnya
Kita sudah membangun sistem integrasi kesehatan yang lengkap dan sudah diverifikasi berjalan di MacBook Pro M4 Pro: 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. M4 Pro membuat developer experience jauh lebih menyenangkan — build Gradle yang cepat, Docker container yang jalan native tanpa emulasi, dan RAM 24GB yang cukup untuk menjalankan semua service sekaligus.
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