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.

✍️ Mohamad Yusuf Ibrahim · Senior Java Backend Engineer · 10+ tahun di Banking & Financial Services
Spring Boot 3.3 HAPI FHIR 7.4 Apache Camel 4.6 MLLP FHIR R4 SATUSEHAT Docker Compose HL7 v2 Apple M4 Pro macOS

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.

Arsitektur Healthcare Integration Service SIMRS lama mengirim HL7 v2 via MLLP ke Healthcare Integration Service yang terdiri dari MLLP Listener, Transformer, dan FHIR Service, kemudian diteruskan ke HAPI FHIR JPA Server lokal dan SATUSEHAT API. Healthcare Integration Service SIMRS Lama Simulator HL7 v2 MLLP:2575 MLLP Listener Apache Camel ACK Transformer HL7 v2 → FHIR FHIR Service — Patient · Encounter · Observation HAPI FHIR Server lokal · localhost:8090 SATUSEHAT Kemenkes API

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.

⚠️ Peringatan Apple Silicon: Jangan Pakai Java Bawaan macOS macOS Sequoia menyertakan Java runtime di /usr/bin/java, tapi ini versi sistem yang dikontrol Apple dan bukan untuk development. Kita butuh JDK 21 yang diinstall sendiri dan harus berjalan sebagai aarch64, bukan Rosetta.

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
💡 Tip M4 Pro: Naikkan Memory Docker Default Docker Desktop hanya alokasi 8GB. Dengan RAM 24GB di M4 Pro, aman untuk naikan ke 12GB: Docker Desktop → Settings → Resources → Memory → 12 GB. HAPI FHIR JPA Server saat startup bisa makan 1.5–2GB sendiri.

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.

ToolVersiCara Install di M4 ProVerifikasi
JDK 2121.x (LTS)brew install --cask temurin@21java -version → aarch64
Docker Desktop4.30+brew install --cask dockerdocker info | grep Architecture
IntelliJ IDEA2024.xbrew install --cask intellij-ideaAbout → AArch64
Git2.xSudah ada, atau brew install gitgit --version
Akun SATUSEHATDaftar di platform.satusehat.kemkes.go.idLogin berhasil

Bagian 1 — Setup Project Spring Boot

Struktur Project

Kita pakai single-module project dengan package yang jelas per domain:

healthcare-integration-service/
├── 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}"
}
💡 Mengapa WebFlux hanya untuk WebClient? Kita tidak menggunakan reactive stack secara penuh. WebFlux di sini hanya sebagai penyedia WebClient — HTTP client non-blocking yang lebih modern dari RestTemplate dan cocok untuk memanggil SATUSEHAT API dengan retry logic.
🍎 Tip M4 Pro: Gradle Toolchain Auto-Detection Konfigurasi java.toolchain.languageVersion = 21 di build.gradle akan otomatis menemukan Temurin 21 yang sudah diinstall via Homebrew. Jika Gradle tidak menemukannya, tambahkan ini di ~/.gradle/gradle.properties:
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.

⚠️ Field Mapping adalah Tanggung Jawab Klinis Selalu libatkan tenaga kesehatan (dokter, apoteker, atau informaticist medis) untuk memverifikasi mapping antara field HL7 v2 dan FHIR resource. Kode di bawah adalah contoh teknis — validasi klinis tetap diperlukan sebelum produksi.
📄 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);
    }
}
⚠️ Urutan Pengiriman ke SATUSEHAT Wajib Diperhatikan SATUSEHAT melakukan referential integrity check. Urutan yang benar: Patient → Organization/Practitioner → Encounter → Condition/Observation/Procedure. Jika Encounter dikirim sebelum Patient-nya ada, akan mendapat error 422 Unprocessable Entity.

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 Images di Apple Silicon Semua image yang digunakan (postgres:16-alpine, hapiproject/hapi:latest, alpine:3.19) sudah punya manifest ARM64. Docker Desktop akan otomatis pull versi yang tepat. Verifikasi: docker inspect [image] | grep Architecture harus tampil arm64.
📄 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

  • 1
    Clone 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.
  • 2
    Jalankan 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
  • 3
    Jalankan 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.
  • 4
    Kirim test HL7 message Jalankan docker compose up hl7-simulator atau gunakan tool seperti HAPI TestPanel untuk mengirim pesan ADT^A04 ke port 2575.
  • 5
    Verifikasi di FHIR server lokal Buka http://localhost:8090/fhir/Patient?_format=json — Anda harus melihat Patient resource yang baru dibuat dari konversi HL7 v2.
  • 6
    Verifikasi 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
💡 Gunakan HAPI FHIR TestPanel untuk Debugging Download HAPI TestPanel — tool GUI gratis yang bisa mengirim pesan HL7 v2 via MLLP, menampilkan struktur segment, dan memvalidasi ACK response. Jauh lebih nyaman daripada netcat manual.

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)

Gejala: java -version menampilkan output normal tapi performa terasa lambat, atau java -XshowSettings:all -version 2>&1 | grep os.arch menampilkan x86_64 bukan aarch64.
📟 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)

Gejala: docker compose up gagal dengan error Bind for 0.0.0.0:5432 failed: port is already allocated.
📟 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"

Gejala: Error saat docker pull atau docker compose up: no matching manifest for linux/arm64/v8 in the manifest list entries.
📟 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)

Gejala: Dialog muncul: "Do you want the application java to accept incoming network connections?". Atau koneksi MLLP dari simulator ke aplikasi selalu timeout.
📟 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)

Gejala: Container hc-fhir-server terus restart, log menampilkan java.lang.OutOfMemoryError: Java heap space atau container exit dengan code 137 (OOM killed).
📟 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"

Gejala: Build error: Could not find tools.jar atau java.home is set to JRE, not JDK.
📟 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

Gejala: HL7 simulator atau HAPI TestPanel gagal konek ke port 2575 dengan Connection refused.
📟 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)

Gejala: Log menampilkan 401 Unauthorized atau PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException saat mengambil token SATUSEHAT.
📟 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

Gejala: Saat mengirim Patient atau Encounter ke SATUSEHAT, response 422 dengan body berisi OperationOutcome yang menyebutkan field tidak valid.
📟 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)

Gejala: Setelah menambahkan dependency di build.gradle, IntelliJ masih menampilkan error "Cannot resolve symbol 'Patient'" atau class HAPI FHIR merah.
📟 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)

ℹ️ Bukan konflik: macOS Sequoia menggunakan port 7000 dan 5000 untuk AirPlay Receiver, bukan port 8080. Jadi penggunaan port 8080 untuk Spring Boot tidak akan konflik. Tapi jika Anda mendapati error di port lain, cek: System Settings → General → AirDrop & Handoff → AirPlay Receiver. Matikan jika tidak digunakan.

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:

AspekCatatan untuk Produksi
Secret ManagementJangan simpan client_secret di application.yml. Gunakan HashiCorp Vault, AWS Secrets Manager, atau Spring Cloud Config dengan enkripsi.
Dead Letter QueuePesan HL7 yang gagal diproses harus masuk DLQ (Kafka / RabbitMQ) untuk reprocessing manual, bukan sekadar dilog.
IdempotencyMLLP bisa mengirim ulang pesan yang sama (retransmit). Implementasikan idempotency check berdasarkan MSH-10 (Message Control ID).
FHIR ValidationAktifkan validator SATUSEHAT profile sebelum kirim ke produksi. Profil FHIR Kemenkes lebih ketat dari spesifikasi R4 standar.
Audit TrailData klinis wajib punya audit log lengkap (siapa mengubah apa, kapan). Pertimbangkan FHIR AuditEvent resource.
Enkripsi DataData 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

Popular posts from this blog

Numpang Kerja Remote dari Bandung Creative Hub

Debugging PHP Web dengan XDebug di Intellij IDEA (PHP STORM)

Membangun AI Development Assistant Lokal