← semua materi
05:00 Arsitektur Β· 25 Oct 2026 Β· 05:01

πŸ“š Topik: Clean/Hexagonal Architecture β€” Kapan Berguna, Kapan Cuma Upacara

Kategori: System Architecture Β· Level: intermediate–advanced


🎯 Tujuan Belajar

Setelah sesi ini, keputusan berikut jadi lebih baik:

  1. Bisa menolak hexagonal di proyek yang tidak butuh, dengan argumen teknis (bukan selera).
  2. Bisa memilih titik potong boundary yang benar β€” bukan menggambar 4 layer untuk semua hal.
  3. Bisa membedakan dependency inversion sebagai alat testability vs sebagai dogma struktur folder.
  4. Tahu di mana pattern ini gagal secara diam-diam (anemic domain + port yang cuma pass-through).

🧠 Penjelasan Inti

Masalah asli yang diselesaikan. Bukan "kode rapi". Masalahnya: business logic terikat ke detail eksternal β€” framework HTTP, ORM, SDK vendor, skema DB. Kalau ikatan itu kuat, biaya perubahan detail (ganti ORM, tambah channel baru, ganti queue) merambat ke domain, dan domain tidak bisa diuji tanpa infrastruktur.

Inti gagasan (Alistair Cockburn, "Hexagonal Architecture / Ports & Adapters", ~2005): - Aplikasi punya inside (logika) dan outside (dunia). - Komunikasi lintas batas hanya lewat port (interface yang didefinisikan inside) dan adapter (implementasi di outside). - Bentuk "hexagon" cuma visual β€” tujuannya menunjukkan banyak sisi, bukan 6 layer. Kesimetrian penting: ada port driving (input: HTTP, CLI, consumer, test) dan driven (output: DB, queue, payment gateway).

Clean Architecture (Robert C. Martin, 2012) = lapisan konsentris di atas ide yang sama, ditambah Dependency Rule: panah dependency selalu menuju ke dalam. Entity β†’ Use Case β†’ Interface Adapter β†’ Framework/Driver. Nama "clean/hexagonal/onion" sering dipakai bergantian; perbedaannya kosmetik (jumlah cincin dan penamaan), bukan semantik.

Klaim inti yang harus diuji skeptis: "Business logic harus bisa dikompilasi dan diuji tanpa framework." Ini klaim yang benar secara teknis dan sering tidak bernilai secara ekonomi. Seluruh penilaian sesi ini bergantung pada apakah klaim itu menghasilkan leverage nyata di konteks spesifik.


βš™οΈ Cara Kerja Internal

Mekanismenya bukan "folder". Mekanismenya arah kompilasi (compile-time dependency direction).

        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ outside / infra ─────────────┐
        β”‚                                           β”‚
  HTTP handler ──┐                          β”Œβ”€β”€ Postgres adapter
  CLI command ────   (driving adapter)      β”‚   (driven adapter)
  Queue consumer β”˜          β”‚               β”‚         β–²
                            β–Ό               β”‚         β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚   β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
                    β”‚   port IN     β”‚       β”‚   β”‚  port OUT  β”‚
                    β”‚  (interface)  β”‚       β”‚   β”‚ (interface)β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚   β””β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”˜
                            β–Ό               β”‚         β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”
                    β”‚        DOMAIN / USE CASE             β”‚
                    β”‚  (nol import framework, nol import   β”‚
                    β”‚   driver, nol import adapter)        β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Aturan operasional:

  1. Domain tidak mengimpor apa pun dari luar. Bukan "sebaiknya" β€” dijaga oleh modul boundary (Go internal/, Java module, TS eslint-plugin-boundaries, Rust crate terpisah).
  2. Port didefinisikan oleh konsumen, bukan penyedia. UserRepository ditulis di dalam karena use case yang butuh; adapter Postgres yang menyesuaikan. Ini Interface Segregation pada level arsitektur β€” bukan IRepository<T> generik 30 method.
  3. Adapter = translasi, bukan logika. Tugasnya: mapping DTO ↔ domain, error vendor ↔ error domain, transaksi/lifecycle. Kalau adapter punya if bisnis, boundary bocor.
  4. Composition root (satu tempat, biasanya main) yang menyambungkan: NewUserService(postgresRepo, smtpNotifier). Inilah satu-satunya tempat new konkret bertemu interface.

Bentuk nyata di kode (Go, minimal):

// domain β€” nol import eksternal
type Order struct{ id string; total Money; status Status }
func (o *Order) Pay(at time.Time) error {
    if o.status != StatusPending { return ErrInvalidTransition }
    o.status = StatusPaid
    return nil
}

// port β€” ditulis di dalam, karena use case yang butuh
type OrderRepo interface{ Save(context.Context, *Order) error }
type Clock interface{ Now() time.Time }

// use case β€” orkestrasi, bukan logika bisnis
type PayOrder struct{ repo OrderRepo; clock Clock }
func (uc *PayOrder) Do(ctx context.Context, id string) error { ... }
// adapter tipis β€” translasi, tanpa if bisnis
class PgOrderRepo implements OrderRepo {
  async save(ctx, o) {
    await this.db.query('UPDATE orders SET status=$1 WHERE id=$2', [o.status, o.id])
  }
}

Uji yang menentukan apakah boundary-nya nyata: hapus folder adapters/ dari build β€” domain harus tetap kompilasi dan test-nya hijau. Kalau tidak, boundary itu dekorasi.


βš–οΈ Trade-off & Kapan JANGAN Dipakai

Ini bagian yang membedakan senior dari yang hafal diagram.

Dimensi Hexagonal penuh Layered/transaction script
Biaya awal +20–40% kode (port, DTO, mapping) baseline
Biaya per fitur kecil (CRUD) tinggi β€” 4 file untuk 1 endpoint rendah
Biaya ganti infrastruktur rendah (satu adapter baru) tinggi (grep & replace)
Test domain tanpa DB ya, milidetik tidak, butuh container
Onboarding lambat β€” harus paham peta cepat
Cocok untuk domain kompleks, umur panjang, multi-channel CRUD, admin panel, prototype, script

Kapan JANGAN dipakai:

  1. CRUD tanpa invariant. Kalau use case = SELECT, INSERT, UPDATE field, tidak ada aturan yang perlu dilindungi. Port di sini cuma menambah lapisan pass-through. Transaction script menang.
  2. Tim belum punya disiplin modul. Hexagonal tanpa enforcement = folder domain/ yang isinya import prisma. Hasilnya lebih buruk dari layered: ilusi boundary.
  3. Umur produk < 6 bulan atau belum ada product-market fit. Abstraksi untuk perubahan yang belum terbukti adalah biaya tanpa opsi.
  4. Tim kecil (1–3 orang) dengan domain tunggal. Overhead koordinasi tidak sebanding; boundary bisa ditambahkan belakangan saat nyeri muncul.
  5. Perf-critical path dengan alokasi ketat. Mapping DTO↔domain berlapis menambah alokasi & pointer chasing. Bukan otomatis salah, tapi harus diukur β€” jangan diasumsikan gratis.

Poin yang sering hilang: hexagonal tidak menyelesaikan distributed systems, consistency, atau concurrency. Ini pattern struktur kode, bukan pattern sistem terdistribusi. Banyak engineer salah harap di sini.

Alternatif lebih murah yang sering cukup: modular monolith dengan boundary per modul bisnis (billing/, catalog/) + satu interface per dependency eksternal yang benar-benar berisiko diganti. Dapat 80% manfaat, 20% biaya.


🏭 Contoh Produksi & Failure Mode

  1. Anemic domain + port theater. Semua logika di service; entity cuma struct getter/setter. Hasil: 12 port, 12 adapter, 0 aturan bisnis terlindungi. Test tetap butuh DB karena service memanggil repo untuk tiap langkah. Biaya penuh, manfaat nol.
  2. Port yang mengikuti vendor, bukan kebutuhan. interface StripeClient dengan signature identik SDK Stripe. Ganti vendor tetap butuh ubah domain β€” boundary gagal pada tujuannya sendiri.
  3. Boundary bocor lewat tipe. Repository mengembalikan *gorm.DB atau PrismaPromise di return type. Domain jadi terikat diam-diam; kompilasi lolos, ketergantungan nyata.
  4. Mapping explosion. 3 representasi untuk 1 konsep (domain entity, DB model, API DTO) Γ— 40 entitas = ratusan mapper manual. Bug klasik: field baru lupa dimapping, hilang diam-diam di produksi. Mitigasi: mapper terpusat + test round-trip, atau codegen.
  5. Transaksi melintasi boundary. Use case memanggil dua repo, butuh atomisitas. Tanpa Unit of Work atau transaksi yang di-inject lewat port, boundary memaksa Save terpisah β†’ data parsial. Ini failure mode paling umum di implementasi hexagonal naif. Solusi: port TxManager / UnitOfWork, atau transaksi dikelola di adapter dengan pola "callback dalam transaksi".
  6. Over-abstraction pada hal yang tidak akan berubah. Abstraksi Clock berguna (testable waktu). Abstraksi UUIDGenerator untuk satu implementasi = noise.

⚠️ Miskonsepsi Umum


βœ… Cek Pemahaman

  1. Tim kamu punya endpoint admin untuk update harga produk. Aturan bisnis: harga tidak boleh negatif. Apakah ini butuh use case + port, atau cukup handler + ORM? Apa yang mengubah jawabanmu?
  2. Kamu menaruh port PaymentGateway di dalam domain, tapi signature-nya mengikuti SDK vendor persis. Apa masalahnya, dan bagaimana signature yang benar terlihat?
  3. Use case TransferFunds memanggil debit() lalu credit() lewat dua port repo terpisah. Di mana letak risiko konsistensi, dan dua cara menyelesaikannya tanpa membocorkan SQL ke domain?

πŸ”‘ Poin Kunci


➑️ Eksplorasi Lanjut