Layered architecture menata aplikasi menjadi tumpukan lapisan, tetapi satu pertanyaan mengganjal: mengapa domain — kode yang paling berharga — berada di lapisan tengah yang bergantung pada database di bawahnya? Alistair Cockburn mengajukan jawaban pada 2005 lewat Hexagonal Architecture (juga disebut ports and adapters): letakkan domain di pusat, kelilingi dengan port berupa interface, dan gantungkan seluruh detail teknologi sebagai adapter yang dapat dicabut-pasang. Nama “heksagon” bukan berarti bentuk enam sisi wajib — banyak sisi itu hanya pengingat bahwa jumlah port dan adapter tidak terbatas dua arah. Chapter ini membangun domain klinik kecil dengan satu port dan dua adapter.

Domain di Pusat dengan Dua Jenis Port #

Dua jenis port mengelilingi domain. Port masuk (driving) adalah pintu yang dipukul dunia luar untuk memicu use case — tombol antarmuka, REST endpoint, pesan antrean. Port keluar (driven) adalah kebutuhan domain terhadap dunia luar — penyimpanan, pengiriman pesan, pencarian harga — yang dinyatakan sebagai interface milik domain. Adapter masuk memanggil domain; adapter keluar dipanggil domain.

Aturan mainnya satu kalimat: panah dari domain hanya sampai ke interface; seluruh nama teknologi — HashMap, JDBC, HTTP, SQL — hidup di adapter, bukan di pusat. Efeknya simetris dengan dependency inversion, tetapi dinyatakan dalam arsitektur aplikasi utuh, bukan pada satu class.

Satu Port dengan Dua Adapter #

Program berikut memuat domain pendaftaran pasien yang sama dijalankan dua kali dengan adapter penyimpanan berbeda — pertama penyimpanan memori, lalu jejak audit yang hanya merekam:

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public class KlinikHexagonal {
    public static void main(String[] args) {
        System.out.println("== Adapter penyimpanan memori ==");
        jalankanSkenario(new PenyimpanMemori());

        System.out.println("== Adapter jejak audit ==");
        jalankanSkenario(new PenyimpanJejak());
    }

    static void jalankanSkenario(PenyimpanPasien penyimpan) {
        LayananPendaftaran layanan = new LayananPendaftaran(penyimpan);
        PendaftaranKonsol konsol = new PendaftaranKonsol(layanan);

        konsol.daftar("Budi", 30);
        konsol.daftar("Siti", 25);
        konsol.tampilkan("Budi");
        konsol.tampilkan("Dewi");
    }
}

// ===== Domain (pusat) =====
record Pasien(String nama, int umur) {
}

// Port keluar (driven): kebutuhan domain terhadap penyimpanan
interface PenyimpanPasien {
    void simpan(Pasien pasien);

    Optional<Pasien> cari(String nama);
}

// Use case domain: tidak mengenal teknologi apa pun
class LayananPendaftaran {
    private final PenyimpanPasien penyimpan;

    LayananPendaftaran(PenyimpanPasien penyimpan) {
        this.penyimpan = penyimpan;
    }

    String daftar(String nama, int umur) {
        if (nama == null || nama.isBlank()) {
            return "Pendaftaran gagal: nama kosong";
        }
        if (umur < 0) {
            return "Pendaftaran gagal: umur tidak valid";
        }
        penyimpan.simpan(new Pasien(nama.trim(), umur));
        return "Terdaftar: " + nama.trim();
    }

    Optional<Pasien> cari(String nama) {
        return penyimpan.cari(nama);
    }
}

// ===== Adapter masuk (driving): menyampaikan permintaan =====
class PendaftaranKonsol {
    private final LayananPendaftaran layanan;

    PendaftaranKonsol(LayananPendaftaran layanan) {
        this.layanan = layanan;
    }

    void daftar(String nama, int umur) {
        System.out.println(layanan.daftar(nama, umur));
    }

    void tampilkan(String nama) {
        System.out.println(layanan.cari(nama)
                .map(p -> "Ditemukan: " + p.nama() + " (" + p.umur() + " tahun)")
                .orElse("Tidak ditemukan: " + nama));
    }
}

// ===== Adapter keluar (driven): implementasi A =====
class PenyimpanMemori implements PenyimpanPasien {
    private final Map<String, Pasien> data = new LinkedHashMap<>();

    public void simpan(Pasien pasien) {
        data.put(pasien.nama(), pasien);
    }

    public Optional<Pasien> cari(String nama) {
        return Optional.ofNullable(data.get(nama));
    }
}

// ===== Adapter keluar (driven): implementasi B =====
class PenyimpanJejak implements PenyimpanPasien {
    private int jumlah = 0;

    public void simpan(Pasien pasien) {
        jumlah++;
        System.out.println("[AUDIT] simpan #" + jumlah + ": " + pasien.nama());
    }

    public Optional<Pasien> cari(String nama) {
        return Optional.empty(); // jejak audit tidak menyimpan ulang
    }
}

Output yang Dihasilkan:

text
== Adapter penyimpanan memori ==
Terdaftar: Budi
Terdaftar: Siti
Ditemukan: Budi (30 tahun)
Tidak ditemukan: Dewi
== Adapter jejak audit ==
[AUDIT] simpan #1: Budi
Terdaftar: Budi
[AUDIT] simpan #2: Siti
Terdaftar: Siti
Tidak ditemukan: Budi
Tidak ditemukan: Dewi

Mari kita bedah posisi setiap class pada heksagon tersebut:

  1. Baris 26-34 (record Pasien dan interface PenyimpanPasien): keduanya adalah domain — data beserta kebutuhannya. Interface PenyimpanPasien tidak membocorkan satu pun istilah teknologi; mengganti database tidak pernah menyentuh baris-baris ini.
  2. Baris 37-57 (class LayananPendaftaran): use case pusat hanya mengenal port keluar. Validasi nama dan umur terjadi di sini sekali — adapter mana pun yang dipasang, aturannya tetap.
  3. Baris 14-22 (jalankanSkenario): method ini adalah titik perakitan. main memilih adapter (new PenyimpanMemori() atau new PenyimpanJejak()), menyuntikkannya ke use case, lalu adapter masuk PendaftaranKonsol mengambil alih interaksi.
  4. Baris 93-104 (class PenyimpanJejak): adapter kedua hanya merekam dan selalu menjawab kosong. Perhatikan hasil eksekusi: use case yang sama berjalan mulus — Terdaftar: Budi tetap tercetak, Tidak ditemukan: Budi adalah konsekuensi perilaku adapter ini, bukan kegagalan domain.

Testabilitas sebagai Bonus #

Penyimpan jejak audit di atas sebenarnya adalah test double yang menyamar sebagai adapter: pada pengujian otomatis, kita memasang adapter serupa untuk menjalankan seluruh use case tanpa database sungguhan. Domain yang tidak menyentuh teknologi dapat diuji cepat, paralel, dan deterministik — inilah alasan tim yang mengadopsi heksagon biasanya juga mencatat cakupan pengujian domain mendekati penuh. Sisi lainnya biaya: jumlah interface bertambah, wiring di titik perakitan bertambah, dan untuk aplikasi CRUD sederhana seluruh upaya ini terasa berlebihan.

Pola heksagon membalik panah dependensi, tetapi tidak menuntut bentuk package tertentu. Varian berikutnya merapikan gagasan serupa ke dalam lingkaran konsentris dengan aturan eksplisit per lingkar — Clean Architecture.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

KlinikHexagonal.java

Unduh berkas
java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public class KlinikHexagonal {
    public static void main(String[] args) {
        System.out.println("== Adapter penyimpanan memori ==");
        jalankanSkenario(new PenyimpanMemori());

        System.out.println("== Adapter jejak audit ==");
        jalankanSkenario(new PenyimpanJejak());
    }

    static void jalankanSkenario(PenyimpanPasien penyimpan) {
        LayananPendaftaran layanan = new LayananPendaftaran(penyimpan);
        PendaftaranKonsol konsol = new PendaftaranKonsol(layanan);

        konsol.daftar("Budi", 30);
        konsol.daftar("Siti", 25);
        konsol.tampilkan("Budi");
        konsol.tampilkan("Dewi");
    }
}

// ===== Domain (pusat) =====
record Pasien(String nama, int umur) {
}

// Port keluar (driven): kebutuhan domain terhadap penyimpanan
interface PenyimpanPasien {
    void simpan(Pasien pasien);

    Optional<Pasien> cari(String nama);
}

// Use case domain: tidak mengenal teknologi apa pun
class LayananPendaftaran {
    private final PenyimpanPasien penyimpan;

    LayananPendaftaran(PenyimpanPasien penyimpan) {
        this.penyimpan = penyimpan;
    }

    String daftar(String nama, int umur) {
        if (nama == null || nama.isBlank()) {
            return "Pendaftaran gagal: nama kosong";
        }
        if (umur < 0) {
            return "Pendaftaran gagal: umur tidak valid";
        }
        penyimpan.simpan(new Pasien(nama.trim(), umur));
        return "Terdaftar: " + nama.trim();
    }

    Optional<Pasien> cari(String nama) {
        return penyimpan.cari(nama);
    }
}

// ===== Adapter masuk (driving): menyampaikan permintaan =====
class PendaftaranKonsol {
    private final LayananPendaftaran layanan;

    PendaftaranKonsol(LayananPendaftaran layanan) {
        this.layanan = layanan;
    }

    void daftar(String nama, int umur) {
        System.out.println(layanan.daftar(nama, umur));
    }

    void tampilkan(String nama) {
        System.out.println(layanan.cari(nama)
                .map(p -> "Ditemukan: " + p.nama() + " (" + p.umur() + " tahun)")
                .orElse("Tidak ditemukan: " + nama));
    }
}

// ===== Adapter keluar (driven): implementasi A =====
class PenyimpanMemori implements PenyimpanPasien {
    private final Map<String, Pasien> data = new LinkedHashMap<>();

    public void simpan(Pasien pasien) {
        data.put(pasien.nama(), pasien);
    }

    public Optional<Pasien> cari(String nama) {
        return Optional.ofNullable(data.get(nama));
    }
}

// ===== Adapter keluar (driven): implementasi B =====
class PenyimpanJejak implements PenyimpanPasien {
    private int jumlah = 0;

    public void simpan(Pasien pasien) {
        jumlah++;
        System.out.println("[AUDIT] simpan #" + jumlah + ": " + pasien.nama());
    }

    public Optional<Pasien> cari(String nama) {
        return Optional.empty(); // jejak audit tidak menyimpan ulang
    }
}