π 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:
- Bisa menolak hexagonal di proyek yang tidak butuh, dengan argumen teknis (bukan selera).
- Bisa memilih titik potong boundary yang benar β bukan menggambar 4 layer untuk semua hal.
- Bisa membedakan dependency inversion sebagai alat testability vs sebagai dogma struktur folder.
- 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:
- Domain tidak mengimpor apa pun dari luar. Bukan "sebaiknya" β dijaga oleh modul boundary (Go
internal/, Java module, TSeslint-plugin-boundaries, Rust crate terpisah). - Port didefinisikan oleh konsumen, bukan penyedia.
UserRepositoryditulis di dalam karena use case yang butuh; adapter Postgres yang menyesuaikan. Ini Interface Segregation pada level arsitektur β bukanIRepository<T>generik 30 method. - Adapter = translasi, bukan logika. Tugasnya: mapping DTO β domain, error vendor β error domain, transaksi/lifecycle. Kalau adapter punya
ifbisnis, boundary bocor. - Composition root (satu tempat, biasanya
main) yang menyambungkan:NewUserService(postgresRepo, smtpNotifier). Inilah satu-satunya tempatnewkonkret 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:
- CRUD tanpa invariant. Kalau use case =
SELECT,INSERT,UPDATEfield, tidak ada aturan yang perlu dilindungi. Port di sini cuma menambah lapisan pass-through. Transaction script menang. - Tim belum punya disiplin modul. Hexagonal tanpa enforcement = folder
domain/yang isinya importprisma. Hasilnya lebih buruk dari layered: ilusi boundary. - Umur produk < 6 bulan atau belum ada product-market fit. Abstraksi untuk perubahan yang belum terbukti adalah biaya tanpa opsi.
- Tim kecil (1β3 orang) dengan domain tunggal. Overhead koordinasi tidak sebanding; boundary bisa ditambahkan belakangan saat nyeri muncul.
- 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
- 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.
- Port yang mengikuti vendor, bukan kebutuhan.
interface StripeClientdengan signature identik SDK Stripe. Ganti vendor tetap butuh ubah domain β boundary gagal pada tujuannya sendiri. - Boundary bocor lewat tipe.
Repositorymengembalikan*gorm.DBatauPrismaPromisedi return type. Domain jadi terikat diam-diam; kompilasi lolos, ketergantungan nyata. - 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.
- Transaksi melintasi boundary. Use case memanggil dua repo, butuh atomisitas. Tanpa Unit of Work atau transaksi yang di-inject lewat port, boundary memaksa
Saveterpisah β data parsial. Ini failure mode paling umum di implementasi hexagonal naif. Solusi: portTxManager/UnitOfWork, atau transaksi dikelola di adapter dengan pola "callback dalam transaksi". - Over-abstraction pada hal yang tidak akan berubah. Abstraksi
Clockberguna (testable waktu). AbstraksiUUIDGeneratoruntuk satu implementasi = noise.
β οΈ Miskonsepsi Umum
- "Hexagonal = microservices." Salah. Ini berlaku penuh di monolith. Justru lebih efektif di monolith karena boundary murah ditegakkan.
- "Harus 4 layer persis." Jumlah layer mengikuti kebutuhan. Banyak sistem sukses dengan 2 (domain + adapter). Cincin tambahan tanpa fungsi = biaya navigasi.
- "Port harus satu per tabel." Tidak. Port mengikuti kebutuhan use case, bukan skema DB. Sering: 1 repo port untuk 3 tabel, atau 2 port berbeda untuk 1 tabel (read model vs write).
- "Clean architecture otomatis bikin kode mudah diuji." Yang bikin mudah diuji adalah dependency inversion pada titik yang tepat + desain fungsi murni. Struktur folder tidak menguji apa pun.
- "Domain harus bebas dari ORM, jadi pakai active record manual." Sering jadi reimplementasi ORM yang lebih buruk. Trade-off-nya: pilih mapping eksplisit (biaya) atau keterikatan ORM (risiko) β sadar, bukan otomatis.
- "Boundary cukup didokumentasikan." Boundary tanpa enforcement akan erosion dalam 6β12 bulan. Butuh alat: modul system, lint rule, atau pemisahan crate/package fisik.
β Cek Pemahaman
- 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?
- Kamu menaruh port
PaymentGatewaydi dalam domain, tapi signature-nya mengikuti SDK vendor persis. Apa masalahnya, dan bagaimana signature yang benar terlihat? - Use case
TransferFundsmemanggildebit()lalucredit()lewat dua port repo terpisah. Di mana letak risiko konsistensi, dan dua cara menyelesaikannya tanpa membocorkan SQL ke domain?
π Poin Kunci
- Hexagonal = arah dependency, bukan bentuk folder. Buktikan dengan build tanpa adapter.
- Nilainya = opsi & testability pada titik perubahan yang nyata. Tanpa perubahan nyata, nilainya nol.
- Port ditulis oleh konsumen, mengikuti use case, bukan tabel atau SDK.
- Biaya sebenarnya ada di mapping dan transaksi lintas boundary β dua tempat kegagalan paling sering.
- Domain CRUD tanpa invariant: jangan. Modular monolith + 1β2 interface kunci biasanya cukup.
- Pattern ini tidak menyelesaikan consistency, concurrency, atau distribusi. Jangan salah harap.
- Boundary tanpa enforcement mekanis akan terkikis. Itu bukan soal disiplin tim, itu soal desain.
β‘οΈ Eksplorasi Lanjut
- Unit of Work & transaksi lintas port β cara mempertahankan atomisitas tanpa membocorkan SQL. Ini jembatan ke sesi consistency model.
- Functional core, imperative shell (Gary Bernhardt) β alternatif lebih ringan dari hexagonal: logika murni + shell tipis. Sering menyelesaikan 70% masalah dengan 30% upacara.
- Vertical slice architecture β struktur per fitur, bukan per layer; kritik langsung terhadap biaya navigasi clean architecture.
- Enforcement tooling: Go module boundaries, Java
jdeps/ArchUnit, TSeslint-plugin-boundariesatau Nx module constraints. - Bacaan primer: Cockburn, Hexagonal Architecture (artikel asli, 2005); Martin, The Clean Architecture (2012); Fowler, Patterns of Enterprise Application Architecture β bandingkan Transaction Script vs Domain Model, karena itulah keputusan yang sebenarnya kamu ambil.