← semua materi
05:00 Arsitektur · 09 Nov 2026 · 05:01

🔬 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:

  1. Bisa menjawab "apakah proyek ini butuh port & adapter?" dengan kriteria, bukan dengan selera atau "kata senior di medium article".
  2. Bisa membedakan inversi dependensi yang nyata vs yang palsu (interface yang tetap membocorkan framework).
  3. Bisa menghitung biaya strukturalnya — jumlah file, mapping, ongkos onboarding — dan tahu kapan biaya itu tidak balik.
  4. 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:

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

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:

  1. Inti mendeklarasikan interface (port) dalam bahasa domain.
  2. Adaptor mengimplementasikan interface itu.
  3. Composition root — satu tempat di pinggir aplikasi (biasanya main, Program.cs, bootstrap.ts) — merakit implementasi konkret ke use case, umumnya via constructor injection.
  4. 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:


🏗️ 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

  1. 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?

  2. 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?

  3. Use case memanggil paymentGateway.charge() lalu orderRepo.save(). Transaksi DB dimulai sebelum charge dan di-commit sesudah save. Sebutkan dua mode kegagalan berbeda yang mungkin terjadi, dan bagaimana outbox atau urutan operasi mengubahnya.


🔑 Poin Kunci


➡️ Eksplorasi Lanjut

  1. Transactional Outbox — jembatan antara "simpan state" dan "pancarkan event" tanpa dual-write. Baca bersama pola idempotent consumer.
  2. Functional Core, Imperative Shell (Gary Bernhardt, "Boundaries", 2012) — varian paling ringan dari ide yang sama; cocok kalau hex terasa terlalu berat.
  3. Vertical Slice Architecture (Jimmy Bogard) — counter-movement terhadap layering; pahami kapan ia menang (fitur banyak, domain dangkal).
  4. Modular Monolith — bagaimana menegakkan batas modul tanpa jaringan; pasangan alami hex di dalam satu deployable.
  5. ArchUnit / dependency-cruiser / import-linter — cara mengubah aturan arsitektur dari dokumen jadi test yang gagal di CI.
  6. Aggregate boundary & consistency — lanjutan langsung: setelah port memisahkan storage, pertanyaan berikutnya adalah transaksi mana yang harus atomik.