🔬 BACOT — Sesi Deep Learning 05:00
📚 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 menjawab "apakah proyek ini butuh port & adapter?" dengan kriteria, bukan dengan selera atau "kata senior di medium article".
- Bisa membedakan inversi dependensi yang nyata vs yang palsu (interface yang tetap membocorkan framework).
- Bisa menghitung biaya strukturalnya — jumlah file, mapping, ongkos onboarding — dan tahu kapan biaya itu tidak balik.
- Bisa menegakkan aturan dependensi secara mekanis, bukan lewat konvensi yang lapuk dalam 6 bulan.
🧠 Penjelasan Inti
Masalah yang diselesaikan
Aplikasi biasa tumbuh dengan dependensi satu arah: logika bisnis → framework → database → HTTP. Arah ini terlihat wajar sampai tiga hal terjadi:
- Framework major version naik dan mengubah API. Logika bisnis ikut diubah, padahal aturan bisnisnya tidak berubah.
- Test butuh database, HTTP server, dan container untuk memverifikasi satu aturan diskon. Suite jadi lambat, orang berhenti menulis test.
- Aturan bisnis tersebar: sebagian di service, sebagian di controller, sebagian di stored procedure, sebagian di validasi ORM.
Hexagonal architecture (Alistair Cockburn, 2005) membalik arah itu: inti aplikasi tidak tahu apa pun tentang dunia luar. Dunia luar menempel lewat antarmuka yang didefinisikan oleh inti.
Tiga nama, satu ide
| Nama | Asal | Metafora |
|---|---|---|
| Hexagonal / Ports & Adapters | Cockburn, 2005 | Inti di tengah, banyak sisi tempat adaptor masuk |
| Onion Architecture | Jeffrey Palermo, 2008 | Lapisan konsentris, dependensi ke dalam |
| Clean Architecture | Robert C. Martin, 2012 | Lingkaran konsentris + Dependency Rule |
Perbedaan keduanya kosmetik. Yang identik dan yang penting: Dependency Rule — dependensi source code hanya boleh menunjuk ke dalam. Tidak ada import dari inti ke framework, ORM, HTTP library, atau SDK vendor.
Dua jenis port
- Driving / primary / inbound — aktor luar memanggil masuk. HTTP controller, CLI command, message consumer, cron job, test. Adaptor ini mengubah input dunia luar menjadi panggilan use case.
- Driven / secondary / outbound — inti memanggil keluar. Repository, payment gateway, mailer, cache, event publisher. Inti mendefinisikan apa yang dibutuhkan, adaptor menyediakan bagaimana.
Kunci yang sering terlewat: port dimiliki oleh inti, bukan oleh adaptor. Interface OrderRepository hidup di paket domain, dan bentuknya ditentukan oleh kebutuhan domain — bukan oleh kebutuhan query SQL.
⚙️ Cara Kerja Internal
Mekanisme teknisnya sederhana: itu cuma inversion of control
Tidak ada runtime magic. Yang terjadi:
- Inti mendeklarasikan interface (port) dalam bahasa domain.
- Adaptor mengimplementasikan interface itu.
- Composition root — satu tempat di pinggir aplikasi (biasanya
main,Program.cs,bootstrap.ts) — merakit implementasi konkret ke use case, umumnya via constructor injection. - Saat compile, inti hanya melihat interface. Saat runtime, pointer menunjuk ke implementasi nyata.
Karena itu: hexagonal bukan pola runtime, tapi pola dependensi compile-time. Konsekuensinya, kalau tidak ditegakkan di level modul/build, ia hanya jadi folder cantik.
Bentuk port yang benar vs yang palsu
BURUK (port dibentuk oleh storage):
interface UserRepository {
findActiveUsersCreatedAfterWithOrders(date, status[], limit, offset): Promise<UserRow[]>
}
BAGUS (port dibentuk oleh kebutuhan domain):
interface UserRepository {
findEligibleForRenewal(asOf: Instant): Promise<User[]>
}
Port pertama adalah tabel yang diberi nama interface. Ia tidak mengabstraksi apa pun — mengganti Postgres ke DynamoDB tetap memaksa mengubah domain, karena bahasa query-nya sudah bocor ke dalam. Port kedua menyembunyikan strategi query sepenuhnya; adaptor SQL bebas melakukan JOIN, adaptor DynamoDB bebas melakukan dua query.
Tes sederhana: kalau untuk mengimplementasikan port ke storage kedua kamu harus mengubah signature-nya, itu bukan port — itu ORM dengan nama lain.
Penegakan mekanis (bagian yang biasanya dilewatkan)
Aturan dependensi yang hanya dijaga konvensi akan mati. Yang bekerja:
- Batas build nyata. Modul Gradle/Maven terpisah,
internal/di Go, crate terpisah di Rust,workspace+ path restriction di TS. Kalau satu modul, setidaknya paket domain harus bisa dikompilasi tanpa classpath framework. - Architecture test. ArchUnit (JVM),
import-linter(Python),dependency-cruiser(JS/TS),eslint-plugin-boundaries. Assert: paketdomaintidak mengimpororg.springframework,typeorm,net/http, dsb. Jalankan di CI — bukan di wiki. - Composition root tunggal. Hanya satu file boleh
newinfrastruktur. Kalaunew PrismaClient()muncul di 14 tempat, batas sudah bocor.
🏗️ Diagram
DRIVING SIDE DRIVEN SIDE
(aktor memanggil masuk) (inti memanggil keluar)
HTTP controller ─┐ ┌─ PostgresOrderRepo
CLI command ─────┼──► ┌──────────┐ ┌──► StripeGateway
Queue consumer ──┘ │ INTI │──┤
Test ────────────┘ │ │ └──► OutboxPublisher
│ domain │
│ + ports │
│ + usecases│
└──────────┘
▲
dependency rule:
semua panah menuju INTI
Struktur paket minimal yang sudah cukup:
src/
domain/ # entity, value object, domain service, PORT (interface)
application/ # use case / command handler — orkestrasi, transaksi
adapters/
inbound/http/ # controller, DTO request/response, mapper
outbound/db/ # implementasi repository (SQL/ORM)
outbound/stripe/
main.ts # composition root
Tiga lapisan konseptual, bukan tujuh. Menambah lapisan keempat dan kelima biasanya menambah ongkos tanpa menambah isolasi.
Pseudocode: satu use case utuh
// domain/ports.ts — dimiliki inti
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export interface PaymentGateway {
charge(customerId: CustomerId, amount: Money): Promise<PaymentResult>;
}
// application/place-order.ts — orkestrasi, tanpa I/O konkret
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
private readonly clock: Clock, // port kecil, sering dilupakan
) {}
async execute(cmd: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(cmd.items, this.clock.now()); // aturan di domain
const result = await this.payments.charge(cmd.customerId, order.total());
if (result.kind === 'declined') throw new PaymentDeclined(result.reason);
order.markPaid(result.reference);
await this.orders.save(order);
return order.id;
}
}
// main.ts — composition root, satu-satunya yang tahu konkret
const placeOrder = new PlaceOrder(
new PostgresOrderRepository(pool),
new StripeGateway(process.env.STRIPE_KEY),
new SystemClock(),
);
Perhatikan Clock sebagai port. Ini detail kecil yang membedakan struktur yang benar-benar bisa dites cepat dari yang hanya terlihat bisa dites.
⚖️ Trade-off & Kapan JANGAN Dipakai
Ini bagian yang menentukan.
Biaya nyata yang harus dibayar
| Biaya | Bentuk konkret |
|---|---|
| Jumlah file | Satu fitur CRUD jadi 5–7 file (entity, port, use case, repo impl, DTO, mapper, controller). Navigasi "di mana ini terjadi?" jadi lebih sulit tanpa IDE bagus |
| Mapping berlapis | Request DTO → domain → persistence model → response DTO. Setiap map adalah tempat field hilang. Bug "kolom ini kosong di production" sering lahir di sini |
| Transaksi lintas agregat | Batas alami hexagonal adalah agregat. Operasi yang menyentuh 3 agregat dalam satu transaksi jadi canggung; sering berakhir di use case yang menyimpan beberapa repo dengan satu @Transactional, yang secara konsep adalah pelanggaran |
| Onboarding | Developer baru harus belajar peta, bukan kode. Kalau tim < 3 orang dan domain sederhana, biaya ini tidak balik |
| Abstraksi spekulatif | Setiap interface dengan satu implementasi yang dibuat "siapa tahu nanti" adalah utang kognitif, bukan investasi |
Kapan TIDAK dipakai
1. Domain tipis / CRUD. Kalau inti aplikasi adalah form di atas tabel — admin dashboard, tool internal, CRUD API — maka "domain" tidak punya aturan yang layak dilindungi. Transactions Script atau framework MVC standar sudah benar. Menambahkan hex di sini menghasilkan entity anemis + use case yang isinya tiga baris delegasi. Kamu membayar harga penuh DDD dan mendapat nol manfaat isolasi.
2. Umur kode lebih pendek dari umur investasinya. MVP, prototype, validasi pasar. Kalau probabilitas pivot lebih besar dari probabilitas refactor, abstraksi akan dibuang percuma. Tulis langsung, ukur, refactor kalau bertahan.
3. Satu adaptor, tanpa rencana kedua. Jika hanya pernah ada satu HTTP delivery dan satu Postgres, dan tidak ada rencana nyata kedua, port outbound adalah lapisan yang tidak membeli apa pun kecuali biaya. Pengecualian penting di bawah.
4. Ketika port terpaksa mengikuti framework.
Kalau ORM-mu memaksa entity mewarisi base class, atau repository harus mengembalikan IQueryable/DAO aktif, maka abstraksinya palsu sejak lahir. Lebih jujur pakai layered MVC dan akui ketergantungannya, daripada berpura-pura terisolasi.
5. Ketika tim tidak paham tujuannya.
Cargo cult menghasilkan struktur yang benar tapi disiplin nol: domain mengimpor ORM "cuma untuk tipe", use case memanggil prisma langsung "karena cepat". Hasilnya lebih buruk dari monolit berlapis yang jujur — ada ongkos, tidak ada manfaat, dan ilusi kebersihan yang membuat orang berhenti waspada.
Kapan BERGUNA meski hanya satu adaptor
Pengecualian yang sering diabaikan: kompleksitas domain tinggi. Kalau inti berisi ratusan aturan bercabang — pricing, eligibility, regulatory, state machine — hex membayar dirinya lewat kecepatan test, bahkan dengan satu DB.
Test suite yang menguji aturan bisnis dalam milidetik tanpa container mengubah cara tim bekerja: mereka berani mengubah aturan. Test suite yang butuh 40 detik boot untuk satu assert membuat orang berhenti refactor. Itu bukan soal "arsitektur bersih", itu soal biaya perubahan.
Kriteria keputusan ringkas:
| Sinyal | Arah |
|---|---|
| Domain rule heavy (banyak cabang, invariant, state machine) | Hex core worth it |
| >1 delivery mechanism nyata dalam 2 tahun (API + worker + CLI) | Ports inbound worth it |
| >1 storage/backend nyata atau migrasi terjadwal | Ports outbound worth it |
| Kode hidup 5+ tahun, framework half-life 3–4 tahun | Isolasi membayar |
| CRUD, 1 adaptor, tim kecil, umur pendek | Skip; layered MVC cukup |
| Test lambat jadi bottleneck nyata | Mulai dari memisahkan domain+port, bukan seluruh onion |
🏭 Contoh Produksi & Failure Mode
1. Repository yang mengembalikan ORM entity.
Port terlihat ada, tapi Order adalah entity Prisma/TypeORM dengan lazy relation. Domain jadi tergantung pada sesi DB. Panggilan order.items di dalam domain memicu query — N+1 di lapisan yang seharusnya murni. Gejala: test domain butuh DB, dan performa memburuk saat aturan bisnis makin detail.
2. Ledakan mapper dan field yang hilang.
UserRequest → UserDto → User → UserEntity. Empat model untuk satu tabel. Saat menambah field taxId, developer memperbarui tiga dari empat mapper. Bug muncul sebagai null di laporan keuangan. Ini biaya struktural paling sering tidak dihitung saat memilih hex.
3. Domain event dipancarkan sebelum commit. Use case menyimpan lalu publish event di dalam transaksi. Listener membaca state yang belum committed (tidak terlihat) atau, lebih buruk, listener melakukan efek samping eksternal (kirim email, charge kartu) lalu transaksi rollback. Uang keluar, order tidak ada. Solusi standar: transactional outbox — event ditulis ke tabel yang sama dengan transaksi bisnis, dipancarkan setelah commit oleh relay terpisah.
4. Transaksi lintas agregat di dalam monolit. Use case menyimpan tiga agregat dengan satu transaksi DB, lalu menganggapnya konsisten. Begitu salah satu agregat pindah ke service sendiri, asumsi itu hilang tanpa jejak di kode. Saga/outbox tidak direncanakan karena "kan cuma monolit".
5. Modularitas palsu.
Semua di satu modul build, tanpa arch test. Enam bulan kemudian domain/pricing.ts mengimpor axios untuk memanggil API kurs "karena praktis". Dependency rule sudah mati dan tidak ada yang tahu kapan tepatnya.
6. Interface satu implementasi di mana-mana.
IEmailService dengan satu SmtpEmailService, IClock dengan satu SystemClock, IIdGenerator dengan satu UuidGenerator. Sebagian masuk akal (Clock memungkinkan test deterministik), sebagian murni upacara. Aturan praktis: buat port ketika ada kebutuhan nyata kedua (test double yang mengungkap perilaku, adaptor alternatif, atau batas modul), bukan karena "prinsip".
⚠️ Miskonsepsi Umum
"Hexagonal = DDD." Tidak. Cockburn eksplisit: pola ini tidak mensyaratkan DDD. Kamu bisa punya use case bergaya transaction script di dalam inti hexagonal dan tetap mendapat isolasi dependensi. DDD adalah soal pemodelan domain; hexagonal soal arah dependensi. Keduanya sering dipakai bersama, bukan karena wajib.
"Hexagonal = microservices." Orthogonal sepenuhnya. Hexagonal monolith adalah kombinasi yang sering paling tepat: satu deployable, batas internal jelas, biaya operasional rendah. Sebaliknya, microservice bisa jadi mud ball — batas jaringan tidak memperbaiki batas kode.
"Interface berarti abstraksi." Interface yang menyalin signature implementasi bukan abstraksi, itu indirection. Abstraksi sejati = kontrak kecil, stabil, dalam bahasa domain, cukup sempit sehingga implementasi baru tidak memaksa perubahan konsumen.
"Domain harus tanpa anotasi sama sekali." Batas yang penting adalah dependensi source code, bukan kemurnian anotasi. Sebagian anotasi ringan (validasi, serialisasi) pragmatis. Yang merusak adalah ketika domain memanggil API framework secara aktif — @Entity dengan lazy loading, base class ORM, HttpContext, Request.
"Struktur ini otomatis membuat testing mudah." Hanya kalau test benar-benar memakai port. Kalau setiap test integrasi tetap menyalakan Postgres asli karena "lebih realistis", kamu sudah bayar harga struktur tanpa membeli manfaatnya.
"Makin banyak lapisan makin bersih." Di atas 3 lapisan konseptual, tiap lapisan tambahan harus membuktikan isolasi yang diberikannya. Biasanya tidak.
✅ Cek Pemahaman
-
Sebuah port
interface ReportRepository { query(sql: string): Promise<Row[]> }ada di paket domain. Apakah Dependency Rule terpenuhi secara harfiah? Apakah inversinya nyata? Apa yang harus diubah agar benar? -
Tim punya aplikasi internal 8 developer, 3 tahun umur rencana, satu HTTP API, satu Postgres, domain berisi aturan eligibility yang kompleks (30+ kondisi bercabang), test suite saat ini 12 menit karena butuh DB. Apakah hex layak? Bagian mana yang layak dan bagian mana yang tidak?
-
Use case memanggil
paymentGateway.charge()laluorderRepo.save(). Transaksi DB dimulai sebelumchargedan di-commit sesudahsave. Sebutkan dua mode kegagalan berbeda yang mungkin terjadi, dan bagaimana outbox atau urutan operasi mengubahnya.
🔑 Poin Kunci
- Hexagonal = Dependency Rule, bukan tumpukan folder. Kalau tidak ditegakkan di level build/CI, ia tidak ada.
- Port dimiliki inti dan dibentuk oleh kebutuhan domain. Port yang meniru ORM bukan abstraksi.
- Biayanya nyata: file bertambah, mapping berlapis, transaksi lintas agregat canggung, onboarding lebih lambat.
- Pemicu yang sah: domain rule heavy (kecepatan test), >1 adaptor nyata, umur panjang vs framework churn, banyak tim butuh kontrak stabil.
- Pemicu yang tidak sah: "best practice", "biar clean", "kata artikel". CRUD tipis + satu adaptor = layered MVC sudah cukup.
- Alternatif lebih murah yang menangkap sebagian besar manfaat: Functional Core, Imperative Shell (logika murni + shell I/O tipis) dan Vertical Slice Architecture (potong per fitur, bukan per lapisan teknis). Keduanya memberi test cepat tanpa biaya mapper berlapis.
- Konsistensi lintas agregat tidak diselesaikan oleh hex. Itu masalah transaksi/distribusi — outbox, saga, atau satu agregat yang lebih besar.
➡️ Eksplorasi Lanjut
- Transactional Outbox — jembatan antara "simpan state" dan "pancarkan event" tanpa dual-write. Baca bersama pola idempotent consumer.
- Functional Core, Imperative Shell (Gary Bernhardt, "Boundaries", 2012) — varian paling ringan dari ide yang sama; cocok kalau hex terasa terlalu berat.
- Vertical Slice Architecture (Jimmy Bogard) — counter-movement terhadap layering; pahami kapan ia menang (fitur banyak, domain dangkal).
- Modular Monolith — bagaimana menegakkan batas modul tanpa jaringan; pasangan alami hex di dalam satu deployable.
- ArchUnit / dependency-cruiser / import-linter — cara mengubah aturan arsitektur dari dokumen jadi test yang gagal di CI.
- Aggregate boundary & consistency — lanjutan langsung: setelah port memisahkan storage, pertanyaan berikutnya adalah transaksi mana yang harus atomik.