Hexagonal Architecture, juga dikenal sebagai Ports and Adapters, adalah pola yang diperkenalkan Alistair Cockburn untuk mengisolasi core domain dari segala bentuk input/output eksternal. Aplikasi digambarkan sebagai hexagon dengan port di setiap sisinya: port driving (primary) yang menerima permintaan dari dunia luar, dan port driven (secondary) yang dipakai aplikasi untuk memanggil dunia luar. Pada chapter ini kita akan membangun aplikasi manajemen tugas dengan core domain murni, dua driving adapter (CLI dan REST), dan beberapa driven adapter (in-memory repository, console notifier, file notifier).

Port Driving dan Port Driven #

Dua jenis port yang menjadi ciri khas hexagonal:

Jenis portArah komunikasiContoh
Driving (primary)Dunia luar memanggil aplikasiCLI, REST controller, GraphQL resolver
Driven (secondary)Aplikasi memanggil dunia luarRepository,Notifier, Message bus

Driving port adalah interface yang didefinisikan oleh aplikasi dan diimplementasikan oleh adapter di luar. Driven port juga didefinisikan oleh aplikasi, tetapi diimplementasikan oleh adapter yang menyediakan akses ke DB, email, atau service eksternal. Aplikasi tidak tahu bahwa ada CLI atau REST; ia hanya tahu bahwa ada port TugasApi yang harus dipanggil.

Implementasi Hexagonal untuk Manajemen Tugas #

kotlin
// Demo Hexagonal Architecture (Ports and Adapters): core domain terisolasi.

import java.time.LocalDate

// === CORE DOMAIN: tidak tahu tentang DB, web, atau external services ===
data class Tugas(val id: Int, val judul: String, val selesai: Boolean)

class ManajerTugas {
    private val tugas = mutableMapOf<Int, Tugas>()
    private var nextId = 1

    fun buat(judul: String): Tugas {
        val t = Tugas(nextId++, judul, false)
        tugas[t.id] = t
        return t
    }

    fun tandaiSelesai(id: Int): Tugas? {
        val t = tugas[id] ?: return null
        val updated = t.copy(selesai = true)
        tugas[id] = updated
        return updated
    }

    fun semua(): List<Tugas> = tugas.values.toList()
    fun cari(id: Int): Tugas? = tugas[id]
}

// === PORTS: interfaces yang mendefinisikan interaksi core domain dengan dunia luar ===

// Driving port (primary): bagaimana dunia luar memanggil core
interface TugasApi {
    fun buatTugas(judul: String): Tugas
    fun selesaikanTugas(id: Int): Tugas?
    fun daftarTugas(): List<Tugas>
}

// Driven port (secondary): bagaimana core domain memanggil dunia luar
interface TugasRepository {
    fun simpan(tugas: Tugas)
    fun muat(id: Int): Tugas?
}

interface NotifierPort {
    fun kirim(pesan: String)
}

// === APPLICATION SERVICE: mengorkestrasi core domain + ports ===
class TugasService(
    private val manager: ManajerTugas,
    private val repo: TugasRepository,
    private val notifier: NotifierPort
) : TugasApi {
    override fun buatTugas(judul: String): Tugas {
        val t = manager.buat(judul)
        repo.simpan(t)
        notifier.kirim("Tugas dibuat: ${t.judul}")
        return t
    }

    override fun selesaikanTugas(id: Int): Tugas? {
        val t = manager.tandaiSelesai(id) ?: return null
        repo.simpan(t)
        notifier.kirim("Tugas selesai: ${t.judul}")
        return t
    }

    override fun daftarTugas(): List<Tugas> = manager.semua()
}

// === ADAPTERS: implementasi konkret dari ports ===

// Driving adapter: console UI
class CliTugasAdapter(private val api: TugasApi) {
    fun jalankan() {
        println("=== Hexagonal: CLI Adapter ===")
        val t1 = api.buatTugas("Belajar Kotlin")
        val t2 = api.buatTugas("Review PR tim")
        api.selesaikanTugas(t1.id)
        println("Daftar tugas:")
        api.daftarTugas().forEach { println("  ${it.id}. [${if (it.selesai) "x" else " "}] ${it.judul}") }
    }
}

// Driving adapter: REST API (simulasi)
class RestTugasAdapter(private val api: TugasApi) {
    fun handlePost(judul: String) = api.buatTugas(judul)
    fun handleGetAll() = api.daftarTugas()
}

// Driven adapter: in-memory repository
class InMemoryTugasRepository : TugasRepository {
    private val store = mutableMapOf<Int, Tugas>()
    override fun simpan(tugas: Tugas) { store[tugas.id] = tugas }
    override fun muat(id: Int): Tugas? = store[id]
}

// Driven adapter: email notifier
class ConsoleNotifier : NotifierPort {
    override fun kirim(pesan: String) = println("[notifier] $pesan")
}

// Driven adapter: file notifier (simulasi)
class FileNotifier : NotifierPort {
    override fun kirim(pesan: String) = println("[file] append log: $pesan")
}

fun main() {
    println("=== Demo Hexagonal Architecture ===")

    val manager = ManajerTugas()
    val repo = InMemoryTugasRepository()
    val notifier = ConsoleNotifier()
    val service = TugasService(manager, repo, notifier)

    // Adapter driving: console
    CliTugasAdapter(service).jalankan()

    println()
    println("--- Ganti adapter: REST + FileNotifier ---")
    val fileNotif = FileNotifier()
    val service2 = TugasService(ManajerTugas(), InMemoryTugasRepository(), fileNotif)
    val rest = RestTugasAdapter(service2)
    rest.handlePost("Tulis dokumentasi")
    rest.handlePost("Deploy ke staging")
    println("Via REST GET: ${rest.handleGetAll().size} tugas")
}

Output yang Dihasilkan:

text
=== Demo Hexagonal Architecture ===
=== Hexagonal: CLI Adapter ===
[notifier] Tugas dibuat: Belajar Kotlin
[notifier] Tugas dibuat: Review PR tim
[notifier] Tugas selesai: Belajar Kotlin
Daftar tugas:
  1. [x] Belajar Kotlin
  2. [ ] Review PR tim

--- Ganti adapter: REST + FileNotifier ---
[file] append log: Tugas dibuat: Tulis dokumentasi
[file] append log: Tugas dibuat: Deploy ke staging
Via REST GET: 2 tugas

Bedah Kode Baris-per-Baris #

Mari kita amati bagian kunci:

  1. Baris 6-27 (ManajerTugas): Core domain tidak mengimpor apapun terkait DB atau HTTP. Ia hanya mengelola daftar tugas dalam memori murni.
  2. Baris 31-46 (ports): Tiga interface mendefinisikan batas interaksi. TugasApi adalah driving port yang dipanggil dunia luar; TugasRepository dan NotifierPort adalah driven port yang dipakai aplikasi untuk memanggil dunia luar.
  3. Baris 49-69 (TugasService): Application service mengorkestrasi core domain dan port. Ia mengimplementasi TugasApi (driving) sekaligus memakai TugasRepository dan NotifierPort (driven) sebagai dependensi.
  4. Baris 74-89 (CliTugasAdapter, RestTugasAdapter): Dua driving adapter berbeda yang sama-sama memanggil TugasApi. Mengganti CLI ke REST hanya menyentuh adapter, tidak menyentuh service atau core.
  5. Baris 92-106 (driven adapters): Implementasi konkret untuk TugasRepository dan NotifierPort. ConsoleNotifier mencetak ke stdout; FileNotifier mensimulasikan penulisan ke file. Keduanya dapat diganti tanpa mengubah application service.

Hexagonal vs Clean vs Layered #

AspekLayeredCleanHexagonal
OrientasiVertikal (layer)Vertikal (ring)Horizontal (port)
Ekspresif adapterTidak eksplisitEksplisit via interfaceSangat eksplisit (driving vs driven)
Cocok untukAplikasi menengahDomain kompleksAplikasi banyak integrasi
BoilerplateSedangTinggiTinggi

Penutup #

Hexagonal Architecture memisahkan aplikasi menjadi core domain murni dan beberapa adapter yang dapat diganti-bebas. Pola ini sangat berguna ketika integrasi eksternal banyak dan sering berubah. Pada chapter berikutnya kita akan naik ke Domain-Driven Design: metodologi yang melengkapi arsitektur hexagonal dengan pendekatan pemodelan domain yang sistematis.

Referensi #

Referensi Lanjutan #

  • Kotlin documentation home — halaman utama dokumentasi Kotlin untuk interface sebagai port dan adapter idiomatik di hexagonal architecture