Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Apa itu hexagonal architecture dengan bahasa sederhana?
Hexagonal architecture, atau ports and adapters, menaruh business logic di dalam core yang tidak bergantung pada apa pun di luarnya. Core membuka port berupa interface, dan teknologi seperti HTTP, Postgres atau message queue terhubung lewat adapter. Dengan begitu Anda bisa mengganti atau memalsukan adapter mana pun tanpa menyentuh aturan bisnis.
02Apa bedanya port dan adapter?
Port adalah interface milik core aplikasi yang menggambarkan satu percakapan yang bermakna, misalnya menyimpan order. Adapter adalah kode spesifik teknologi yang mengimplementasikan atau memanggil port itu, seperti Postgres repository atau HTTP controller. Satu port bisa punya beberapa adapter, misalnya database sungguhan dan fake in-memory.
03Apa itu driving port dan driven port?
Driving atau primary port adalah yang dipanggil dunia luar agar aplikasi melakukan sesuatu, seperti use case PlaceOrder yang dipanggil controller. Driven atau secondary port adalah yang dipanggil aplikasi untuk menjangkau sesuatu di luar, seperti OrderRepository. Bedanya ada pada siapa yang memulai percakapan.
04Apakah hexagonal architecture sama dengan clean architecture?
Keduanya berbagi ide inti bahwa source code dependency mengarah ke dalam menuju domain, dan Robert Martin menyebut hexagonal architecture sebagai salah satu pengaruh Clean Architecture. Clean Architecture menetapkan ring bernama seperti entities dan use cases, sedangkan hexagonal hampir tidak menetapkan struktur selain port dan adapter. Dalam praktik, satu project bisa memenuhi keduanya.
05Kapan sebaiknya tidak memakai hexagonal architecture?
Lewati untuk CRUD sederhana, prototype, script jangka pendek, atau project dengan satu driver dan satu database yang tidak akan berganti. Biayanya adalah lebih banyak file dan indirection: di contoh ini satu use case butuh delapan file, bukan tiga. Perkenalkan port hanya untuk dependency yang paling dulu menyakitkan, biasanya database.
Hexagonal Architecture (Ports and Adapters) di TypeScript
Apa itu hexagonal architecture, bagaimana ports dan adapters menjaga business logic bebas dari framework dan database, plus contoh NestJS dan Postgres.
Hexagonal architecture, atau ports and adapters, menjaga business logic di dalam core yang tidak bergantung pada apa pun di luarnya. Core mendefinisikan port, yaitu interface untuk apa yang ditawarkan dan dibutuhkannya, lalu adapter seperti HTTP controller atau Postgres repository dipasang ke port itu. Saat test, adapter in-memory menggantikan database.
Test untuk order service yang butuh container Postgres menyala, schema yang sudah di-migrate dan seed script, hanya untuk memastikan dua line item dijumlahkan dengan benar, sedang menguji hal yang keliru. Hitungannya cuma satu baris kode, dan setup di sekelilingnya yang menyakitkan. Di pekerjaan ERP dan POS, logic yang layak dilindungi memang aturan seperti ini, dan database seharusnya menjadi bagian yang bisa diganti.
Post ini menjawab pertanyaan yang banyak dicari: apa itu hexagonal architecture dan bagaimana menerapkannya? Kita mengikuti artikel asli Alistair Cockburn tahun 2005, lalu membangun satu order service kecil di TypeScript dengan fungsi domain, repository port, adapter Postgres dan adapter in-memory, yang di-wire di NestJS. Kodenya lolos type-check dengan tsc strict dan test-nya sudah dijalankan; outputnya ditampilkan.
Apa itu hexagonal architecture dan dari mana asalnya?
Alistair Cockburn menerbitkannya pada 2005 sebagai HaT Technical Report 2005.02, dengan nama Hexagonal Architecture dan nama alternatif Ports and Adapters. Tujuannya: memungkinkan aplikasi digerakkan secara setara oleh pengguna, program lain, automated test atau batch script, dan dikembangkan serta diuji terpisah dari device dan database yang dipakai saat runtime.
Masalah yang ia perbaiki adalah business logic yang merembes ke user interface. Menurutnya itu membuat automated test sulit ditulis, membuat perpindahan dari sistem yang digerakkan manusia ke sistem batch tidak praktis, dan menyulitkan program lain untuk menggerakkan aplikasi. Bentuk heksagonnya sendiri tidak bermakna: ia menyebut bentuk itu bukan heksagon karena angka enam penting, melainkan sekadar memberi ruang untuk menggambar port sebanyak yang dibutuhkan aplikasi.
Apa itu dependency rule dan kenapa domain tidak bergantung pada apa pun?
Setiap source code dependency mengarah ke dalam. Domain tidak bergantung pada apa pun. Application layer bergantung pada domain. Adapter bergantung pada application dan domain. Database driver, web framework dan message broker berada di sisi luar, dan tidak ada yang di dalam yang meng-import mereka.
Inilah yang membuat sebuah port menjadi interface milik core, bukan milik teknologinya. Core menyatakan kebutuhannya dengan bahasanya sendiri, misalnya simpan order dan cari order berdasarkan id. Adapter yang kemudian melakukan penerjemahan. Kalau aturan ini terjaga, Anda bisa menghapus package pg dari package.json dan folder domain serta application tetap bisa dikompilasi.
Cara paling umum aturan ini rusak adalah port yang mengembalikan ORM entity atau raw row database. Begitu interface menyebut bentuk tabel, core ternyata bergantung pada database. Port hanya berbicara dengan tipe domain.
Apa beda driving port dan driven port?
Cockburn membagi dunia luar menjadi primary dan secondary actor, yang juga disebut driving dan driven. Pembedanya adalah siapa yang memulai percakapan, dan itu menentukan port berada di sisi mana heksagon.
Driving (primary) port: aplikasi yang menawarkannya dan dunia luar yang memanggilnya. Di contoh ini adalah interface PlaceOrder. Adapter-nya adalah HTTP controller, perintah CLI atau queue consumer.
Driven (secondary) port: aplikasi yang membutuhkannya dan memanggil keluar lewat port itu. Di contoh ini adalah interface OrderRepository. Adapter-nya adalah Postgres repository dan fake in-memory.
Pengganti saat test juga berbeda. Driving adapter diganti dengan test yang memanggil port langsung, sedangkan driven adapter diganti dengan fake atau mock.
Banyak tim hanya menulis driven port karena repository adalah titik sakit yang paling jelas. Memberi use case driving interface sendiri bersifat opsional, tetapi itulah yang membuat controller, cron job dan webhook bisa memanggil kode yang sama tanpa menyalinnya.
Bagaimana membangunnya di TypeScript dan NestJS?
Mulai dari folder tree. Batasnya adalah batas folder: folder domain dan application tidak meng-import apa pun dari infrastructure, dan hanya infrastructure yang meng-import NestJS atau pg.
src/orders/
domain/
order.ts // Order, OrderLine, priceOrder() - pure rules
application/
order-repository.port.ts // driven port (interface)
place-order.service.ts // driving port (PlaceOrder) + its implementation
infrastructure/
orders.controller.ts // driving adapter: HTTP
postgres-order.repository.ts // driven adapter: Postgres
in-memory-order.repository.ts // driven adapter: tests
tokens.ts // DI tokens (Symbols)
orders.module.ts // the only file that knows which adapter runs
place-order.test.ts
Domain adalah fungsi murni berisi aturan bisnis. Port adalah interface dalam bahasa domain. Use case adalah class biasa yang menerima port lewat constructor. Tidak satu pun dari ketiga file itu berisi decorator atau import database.
// domain/order.ts - no imports at all
export function priceOrder(lines: OrderLine[]): number {
if (lines.length === 0) throw new EmptyOrderError("An order needs at least one line");
return lines.reduce((sum, line) => {
if (!Number.isInteger(line.quantity) || line.quantity <= 0) {
throw new InvalidQuantityError("Bad quantity for " + line.sku);
}
return sum + line.quantity * line.unitPriceCents;
}, 0);
}
// application/order-repository.port.ts - driven port, domain language only
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// application/place-order.service.ts - plain class, no decorators, no "pg"
export class PlaceOrderService implements PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly newId: () => string,
) {}
async execute(command: PlaceOrderCommand): Promise<Order> {
const order: Order = {
id: this.newId(),
customerId: command.customerId,
lines: command.lines,
totalCents: priceOrder(command.lines),
status: "placed",
};
await this.orders.save(order);
return order;
}
}
Adapter berada di infrastructure. Repository in-memory melakukan clone saat masuk dan saat keluar, karena database sungguhan tidak pernah mengembalikan referensi object milik Anda, dan fake yang melakukannya bisa menyembunyikan bug aliasing. Adapter Postgres menerima Queryable minimal yang secara struktural dipenuhi oleh pg Pool, dan memetakan kolom snake_case kembali ke bentuk Order.
// infrastructure/in-memory-order.repository.ts
export class InMemoryOrderRepository implements OrderRepository {
private readonly rows = new Map<string, Order>();
async save(order: Order): Promise<void> {
// structuredClone: a real database never hands back your own object reference.
this.rows.set(order.id, structuredClone(order));
}
async findById(id: string): Promise<Order | null> {
const row = this.rows.get(id);
return row ? structuredClone(row) : null;
}
}
// infrastructure/postgres-order.repository.ts (save only)
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly db: Queryable) {} // a pg Pool satisfies Queryable
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (id, customer_id, status, total_cents, lines) " +
"VALUES ($1, $2, $3, $4, $5) " +
"ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, " +
"total_cents = EXCLUDED.total_cents, lines = EXCLUDED.lines",
[order.id, order.customerId, order.status, order.totalCents, JSON.stringify(order.lines)],
);
}
// findById maps snake_case rows (customer_id, total_cents) back to the Order shape
}
Module adalah satu-satunya tempat yang tahu adapter mana yang berjalan. Custom provider NestJS memakai key provide untuk token dan useClass atau useFactory untuk implementasinya. Dokumentasi NestJS menunjukkan token Symbol yang didaftarkan dengan provide dan useClass, lalu di-inject dengan decorator Inject. Saya memakai useFactory untuk use case agar PlaceOrderService tetap class biasa tanpa import framework, dan controller menjadi satu-satunya file yang membawa Inject.
// infrastructure/orders.module.ts
@Module({
controllers: [OrdersController],
providers: [
{ provide: PG_POOL, useFactory: () => new Pool({ connectionString: process.env.DATABASE_URL }) },
// The one line that picks the adapter. A test module swaps it for useClass: InMemoryOrderRepository.
{ provide: ORDER_REPOSITORY, useFactory: (pool: Pool) => new PostgresOrderRepository(pool), inject: [PG_POOL] },
{
provide: PLACE_ORDER,
useFactory: (repo: OrderRepository) => new PlaceOrderService(repo, randomUUID),
inject: [ORDER_REPOSITORY],
},
],
})
export class OrdersModule {}
// infrastructure/orders.controller.ts - driving adapter
constructor(@Inject(PLACE_ORDER) private readonly placeOrder: PlaceOrder) {}
Untuk type-check, saya memasang package NestJS dan type pg di project scratch lalu menjalankan tsc dengan strict. Hasilnya lolos tanpa error. Adapter Postgres hanya dikompilasi terhadap stub bentuk Queryable, tidak dijalankan ke database sungguhan, jadi anggap SQL-nya template dan uji terhadap schema Anda sendiri.
Pakai token Symbol dan simpan di folder infrastructure. Token string mengundang typo yang baru gagal saat runtime, dan token yang diekspor dari folder application menyeret urusan framework ke dalam core.
Bagaimana hexagonal architecture membuat testing lebih mudah?
Test membangun service asli dengan adapter in-memory. Tanpa container, tanpa schema, tanpa library mocking. Contoh hitungannya: 2 item seharga 25000 ditambah 1 item seharga 15000, yaitu 2 x 25000 = 50000, ditambah 15000 menjadi 65000.
test("totals 2 x 25000 + 1 x 15000 = 65000 and persists the order", async () => {
const repo = new InMemoryOrderRepository();
let n = 0;
const service = new PlaceOrderService(repo, () => "ord-" + ++n);
const order = await service.execute({
customerId: "cust-7",
lines: [
{ sku: "WASH-PREMIUM", quantity: 2, unitPriceCents: 25000 },
{ sku: "WAX", quantity: 1, unitPriceCents: 15000 },
],
});
assert.equal(order.totalCents, 65000);
assert.deepEqual(await repo.findById("ord-1"), order);
});
// $ node --test dist/orders/place-order.test.js
// ✔ totals 2 x 25000 + 1 x 15000 = 65000 and persists the order (0.71ms)
// ✔ rejects an empty order and saves nothing (0.21ms)
// ℹ tests 2 ℹ pass 2 ℹ fail 0
Kedua test lolos dalam waktu di bawah satu milidetik masing-masing saat saya jalankan, tetapi intinya bukan kecepatan run itu. Intinya, aturan order diperiksa di port, dan test yang sama berjalan tanpa diubah terhadap OrderRepository lain mana pun. Tetap sisakan sedikit integration test yang menjalankan adapter Postgres terhadap Postgres sungguhan, karena fake tidak bisa membuktikan SQL Anda benar.
Apa bedanya dengan layered, onion dan clean architecture?
Mereka lebih banyak tumpang tindih daripada berbeda. Onion Architecture karya Jeffrey Palermo (2008) dan Clean Architecture karya Robert Martin (2012) sama-sama menaruh domain di tengah dengan semua coupling mengarah ke dalam, dan Martin menyebut hexagonal architecture Cockburn sebagai salah satu pengaruhnya. Bedanya sebagian besar di kosakata dan seberapa banyak struktur yang ditetapkan.
Aspek
Layered klasik
Onion / Clean
Hexagonal
Gambaran utama
Tumpukan layer dari atas ke bawah
Lingkaran konsentris mengelilingi domain
Bagian dalam, bagian luar dan port di batasnya
Arah dependency
Tiap layer bergantung pada layer di bawahnya, sehingga UI menjangkau data access secara transitif
Hanya ke dalam
Hanya ke dalam, lewat port
Posisi database
Di dasar, menjadi dependency business layer
Di ring terluar sebagai detail
Di balik driven port, bisa diganti adapter
Struktur yang ditetapkan
Layer seperti presentation, business dan data
Ring bernama, misalnya entities, use cases, interface adapters dan frameworks
Hampir tidak ada: port dan adapter sesuai kebutuhan
Kosakata utama
Layer, service, DAO
Entity, use case, gateway
Port, adapter, driving dan driven
Palermo menyebut coupling UI dan business logic ke data access sebagai pelanggar terbesar di desain layered, karena tiap layer bergantung pada layer di bawahnya dan transitive dependency tetap dependency. Kalau Anda sudah menerapkan dependency rule, Anda sudah menjalankan hexagonal architecture apa pun namanya. Clean Architecture Martin mengatakan hal serupa, dan lingkarannya hanya skematik, tanpa aturan bahwa harus tepat empat.
Berapa biayanya, dan kapan tidak perlu repot?
Biayanya adalah indirection dan jumlah file. Di contoh ini satu use case butuh delapan source file: domain, port, service, controller, dua repository, token dan module. Controller, service dan repository Nest biasa hanya tiga. Setiap file tambahan adalah tempat yang harus dilewati saat debugging, dan orang baru harus belajar kenapa interface itu ada.
Lewati, atau buat minimal, kalau:
Fiturnya kebanyakan CRUD tanpa aturan bisnis yang layak diuji terpisah.
Kodenya prototype atau script yang diperkirakan hidup beberapa minggu, bukan bertahun-tahun.
Hanya ada satu driver, satu database dan hampir tidak ada kemungkinan keduanya berganti.
Timnya satu atau dua orang yang sudah merasa codebase-nya mudah dinavigasi.
Pakai kalau:
Use case yang sama dipanggil dari lebih dari satu tempat, misalnya HTTP, queue consumer dan CLI.
Aturannya layak diuji tanpa infrastruktur, seperti pricing, approval atau pergerakan stok di pekerjaan ERP dan POS.
Sistem eksternal di tepi, seperti payment provider atau printer struk, kemungkinan diganti atau gagal.
Jalan tengah yang masuk akal adalah memperkenalkan port hanya untuk dependency yang paling dulu menyakitkan, biasanya database, lalu menambah sisanya saat adapter kedua menjadi nyata. Saya lebih suka satu port yang jujur daripada port untuk setiap class.
Perlakukan domain sebagai hal yang sedang Anda bangun dan semua yang lain sebagai colokan. Jaga dependency tetap mengarah ke dalam, tulis port dalam bahasa domain, dan sediakan adapter in-memory agar aturan bisa diuji dalam hitungan milidetik. Lalu keluarkan file tambahan hanya di tempat yang adapter kedua atau driver kedua benar-benar mungkin muncul.