Hexagonal Architecture #
Bayangkan sebuah aplikasi yang sudah berjalan dua tahun. Database-nya PostgreSQL, REST API-nya menggunakan Gin, dan email dikirim lewat SMTP. Lalu bisnis memutuskan: migrasi ke MongoDB, tambah gRPC di samping REST, dan ganti vendor email ke SendGrid. Tanpa arsitektur yang tepat, perubahan ini menyentuh hampir seluruh codebase — business logic terseret ikut berubah meskipun tidak ada satu pun aturan bisnis yang berubah. Hexagonal Architecture, yang diperkenalkan oleh Alistair Cockburn, memecahkan masalah ini dengan satu metafora yang kuat: bayangkan aplikasimu sebagai segi enam (hexagon) di tengah, dan semua teknologi eksternal — database, HTTP, message broker, UI — sebagai sesuatu yang menempel di sisi luar melalui konektor yang bisa diganti. Konektor itulah yang disebut adapter, dan lubang tempatnya menempel adalah port. Teknologi berubah, konektor diganti, tapi inti aplikasi tidak perlu disentuh.
Konsep Port dan Adapter #
Hexagonal Architecture berputar di sekitar dua konsep yang saling melengkapi:
Port adalah interface yang didefinisikan oleh application core — sebuah “lubang” berbentuk kontrak yang mendeskripsikan apa yang dibutuhkan core tanpa peduli bagaimana implementasinya. Port dibagi dua:
Adapter adalah implementasi konkret dari sebuah port untuk teknologi tertentu. Adapter “dicolokkan” ke port yang sesuai. Mengganti database berarti mengganti adapter, bukan port-nya.
flowchart LR
subgraph EXT_L["Dunia Luar (Driving)"]
HTTP["HTTP Client"]
CLI["CLI"]
TEST["Test Suite"]
end
subgraph CORE["Application Core"]
IP["Inbound Port\n(interface)"]
APP["Application\nService"]
OP["Outbound Port\n(interface)"]
end
subgraph EXT_R["Dunia Luar (Driven)"]
PG["PostgreSQL"]
KAFKA["Kafka"]
SMTP["SMTP / SendGrid"]
end
HTTP -->|"via Inbound Adapter"| IP
CLI -->|"via Inbound Adapter"| IP
TEST -->|"langsung / mock"| IP
IP --> APP
APP --> OP
OP -->|"via Outbound Adapter"| PG
OP -->|"via Outbound Adapter"| KAFKA
OP -->|"via Outbound Adapter"| SMTP
Ada dua jenis port yang punya peran berbeda:
| Jenis Port | Nama Lain | Siapa yang Memulai | Contoh |
|---|---|---|---|
| Inbound Port | Driving Port | Aktor eksternal memanggil core | CreateOrderUseCase, GetUserService |
| Outbound Port | Driven Port | Core yang memanggil dunia luar | OrderRepository, EmailSender, EventPublisher |
Inbound Port dan Adapter #
Inbound port adalah interface yang mendefinisikan operasi apa yang bisa dilakukan oleh actor eksternal terhadap aplikasi. HTTP handler, CLI command, dan gRPC server semuanya adalah inbound adapter yang memanggil inbound port.
// port/inbound/create_order.go
// Inbound Port: didefinisikan oleh application core
// Actor eksternal (HTTP, CLI, gRPC) memanggil melalui interface ini
package inbound
import "context"
// CreateOrderRequest adalah input DTO yang framework-agnostic
type CreateOrderRequest struct {
CustomerID string
Items []CreateOrderItem
}
type CreateOrderItem struct {
ProductID string
Quantity int
PriceCents int64
}
type CreateOrderResponse struct {
OrderID string
TotalCents int64
Status string
}
// CreateOrderPort adalah inbound port — kontrak yang ditawarkan core ke dunia luar
type CreateOrderPort interface {
CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error)
}
// application/create_order_service.go
// Application Service mengimplementasikan inbound port
package application
import (
"context"
"errors"
"myapp/domain"
"myapp/port/inbound"
"myapp/port/outbound"
)
// CreateOrderService mengimplementasikan inbound.CreateOrderPort
// ✓ Ia tidak tahu apakah dipanggil dari HTTP, CLI, atau gRPC
type CreateOrderService struct {
orderRepo outbound.OrderRepository // outbound port
inventory outbound.InventoryChecker // outbound port
eventBus outbound.EventPublisher // outbound port
}
func NewCreateOrderService(
orderRepo outbound.OrderRepository,
inventory outbound.InventoryChecker,
eventBus outbound.EventPublisher,
) *CreateOrderService {
return &CreateOrderService{
orderRepo: orderRepo,
inventory: inventory,
eventBus: eventBus,
}
}
// CreateOrder mengimplementasikan inbound.CreateOrderPort
func (s *CreateOrderService) CreateOrder(
ctx context.Context,
req inbound.CreateOrderRequest,
) (*inbound.CreateOrderResponse, error) {
if req.CustomerID == "" {
return nil, errors.New("customer ID wajib diisi")
}
// Bangun domain object dari request
items := make([]domain.OrderItem, len(req.Items))
for i, item := range req.Items {
items[i] = domain.OrderItem{
ProductID: domain.ProductID(item.ProductID),
Quantity: item.Quantity,
Price: domain.Money{Cents: item.PriceCents},
}
}
// Cek stok via outbound port
if err := s.inventory.CheckAvailability(ctx, items); err != nil {
return nil, err
}
// Buat domain entity
order, err := domain.NewOrder(domain.CustomerID(req.CustomerID), items)
if err != nil {
return nil, err
}
// Simpan via outbound port
if err := s.orderRepo.Save(ctx, order); err != nil {
return nil, err
}
// Publikasikan event via outbound port
for _, event := range order.PullEvents() {
_ = s.eventBus.Publish(ctx, event)
}
return &inbound.CreateOrderResponse{
OrderID: string(order.ID()),
TotalCents: order.Total().Cents,
Status: string(order.Status()),
}, nil
}
// adapter/inbound/http/order_handler.go
// HTTP Inbound Adapter: menerjemahkan HTTP request ke inbound port
package http
import (
"encoding/json"
"net/http"
"myapp/port/inbound"
)
// OrderHandler adalah inbound adapter untuk HTTP
// ✓ Ia hanya menerjemahkan HTTP → inbound port, tidak ada business logic
type OrderHandler struct {
createOrder inbound.CreateOrderPort
}
func NewOrderHandler(createOrder inbound.CreateOrderPort) *OrderHandler {
return &OrderHandler{createOrder: createOrder}
}
type createOrderHTTPRequest struct {
CustomerID string `json:"customer_id"`
Items []orderItemHTTP `json:"items"`
}
type orderItemHTTP struct {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
Price int64 `json:"price"`
}
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var httpReq createOrderHTTPRequest
if err := json.NewDecoder(r.Body).Decode(&httpReq); err != nil {
http.Error(w, "request tidak valid", http.StatusBadRequest)
return
}
// Map HTTP request → inbound port request
portReq := inbound.CreateOrderRequest{
CustomerID: httpReq.CustomerID,
Items: make([]inbound.CreateOrderItem, len(httpReq.Items)),
}
for i, item := range httpReq.Items {
portReq.Items[i] = inbound.CreateOrderItem{
ProductID: item.ProductID,
Quantity: item.Quantity,
PriceCents: item.Price,
}
}
// Panggil inbound port — tidak tahu implementasinya
resp, err := h.createOrder.CreateOrder(r.Context(), portReq)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
Outbound Port dan Adapter #
Outbound port adalah interface yang didefinisikan oleh application core untuk operasi yang perlu dilakukan ke dunia luar — simpan ke database, kirim email, publikasikan event. Core mendefinisikan kebutuhannya, adapter menyediakan implementasinya.
// port/outbound/repository.go
// Outbound Ports: didefinisikan oleh core, diimplementasikan oleh adapter
package outbound
import (
"context"
"myapp/domain"
)
// OrderRepository adalah outbound port untuk persistence
// ✓ Didefinisikan di sisi core, bukan di sisi database
type OrderRepository interface {
Save(ctx context.Context, order *domain.Order) error
FindByID(ctx context.Context, id domain.OrderID) (*domain.Order, error)
FindByCustomer(ctx context.Context, customerID domain.CustomerID) ([]*domain.Order, error)
}
// InventoryChecker adalah outbound port untuk cek ketersediaan stok
type InventoryChecker interface {
CheckAvailability(ctx context.Context, items []domain.OrderItem) error
}
// EventPublisher adalah outbound port untuk publikasi domain event
type EventPublisher interface {
Publish(ctx context.Context, event domain.DomainEvent) error
}
// EmailSender adalah outbound port untuk pengiriman email
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
// adapter/outbound/postgres/order_repository.go
// PostgreSQL Outbound Adapter: mengimplementasikan outbound.OrderRepository
package postgres
import (
"context"
"database/sql"
"fmt"
"myapp/domain"
"myapp/port/outbound"
)
// PostgresOrderRepository adalah adapter PostgreSQL untuk outbound.OrderRepository
// ✓ Hanya ini yang tahu tentang SQL — core tidak perlu tahu
type PostgresOrderRepository struct {
db *sql.DB
}
// Pastikan PostgresOrderRepository mengimplementasikan interface dengan benar
var _ outbound.OrderRepository = (*PostgresOrderRepository)(nil)
func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
return &PostgresOrderRepository{db: db}
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *domain.Order) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("gagal mulai transaksi: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, customer_id, status, total_cents, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (id) DO UPDATE
SET status = $3, total_cents = $4`,
string(order.ID()), string(order.CustomerID()),
string(order.Status()), order.Total().Cents,
)
if err != nil {
return fmt.Errorf("gagal menyimpan order: %w", err)
}
return tx.Commit()
}
func (r *PostgresOrderRepository) FindByID(
ctx context.Context,
id domain.OrderID,
) (*domain.Order, error) {
// implementasi query...
return nil, nil
}
func (r *PostgresOrderRepository) FindByCustomer(
ctx context.Context,
customerID domain.CustomerID,
) ([]*domain.Order, error) {
// implementasi query...
return nil, nil
}
// adapter/outbound/sendgrid/email_sender.go
// SendGrid Outbound Adapter: mengimplementasikan outbound.EmailSender
package sendgrid
import (
"context"
"fmt"
)
// SendGridEmailSender adalah adapter SendGrid
// ✓ Mengganti vendor email hanya berarti mengganti adapter ini
type SendGridEmailSender struct {
apiKey string
fromEmail string
}
var _ outbound.EmailSender = (*SendGridEmailSender)(nil)
func NewSendGridEmailSender(apiKey, fromEmail string) *SendGridEmailSender {
return &SendGridEmailSender{apiKey: apiKey, fromEmail: fromEmail}
}
func (s *SendGridEmailSender) Send(ctx context.Context, to, subject, body string) error {
// Panggil SendGrid API...
fmt.Printf("[SendGrid] Mengirim ke %s: %s\n", to, subject)
return nil
}
Struktur Direktori #
Struktur folder yang mencerminkan metafora Hexagonal Architecture:
myapp/
├── domain/ ← Inti domain — tidak ada dependency eksternal
│ ├── order.go ← Entity, Aggregate
│ ├── money.go ← Value Object
│ └── events.go ← Domain Events
│
├── port/ ← Kontrak/interface (definisi lubang)
│ ├── inbound/ ← Apa yang bisa dilakukan actor ke core
│ │ ├── create_order.go ← CreateOrderPort
│ │ └── get_order.go ← GetOrderPort
│ └── outbound/ ← Apa yang core butuhkan dari dunia luar
│ ├── repository.go ← OrderRepository, dll
│ ├── event_publisher.go ← EventPublisher
│ └── email_sender.go ← EmailSender
│
├── application/ ← Implementasi inbound port (Application Services)
│ ├── create_order_service.go
│ └── get_order_service.go
│
├── adapter/ ← Implementasi port (konektor ke dunia luar)
│ ├── inbound/
│ │ ├── http/
│ │ │ └── order_handler.go ← HTTP Inbound Adapter
│ │ └── grpc/
│ │ └── order_server.go ← gRPC Inbound Adapter
│ └── outbound/
│ ├── postgres/
│ │ └── order_repository.go ← PostgreSQL Outbound Adapter
│ ├── kafka/
│ │ └── event_publisher.go ← Kafka Outbound Adapter
│ └── sendgrid/
│ └── email_sender.go ← SendGrid Outbound Adapter
│
└── cmd/
└── server/
└── main.go ← Wiring semua adapter ke port
Pengujian dengan Mock Adapter #
Kekuatan terbesar Hexagonal Architecture adalah kemudahan pengujian. Karena core hanya tahu tentang port (interface), kamu bisa memasang mock adapter untuk menguji application service tanpa infrastruktur nyata:
// application/create_order_service_test.go
package application_test
import (
"context"
"testing"
"myapp/application"
"myapp/domain"
"myapp/port/inbound"
)
// Mock adapter untuk OrderRepository
type mockOrderRepository struct {
savedOrders []*domain.Order
shouldFail bool
}
func (m *mockOrderRepository) Save(ctx context.Context, order *domain.Order) error {
if m.shouldFail {
return errors.New("database error")
}
m.savedOrders = append(m.savedOrders, order)
return nil
}
func (m *mockOrderRepository) FindByID(ctx context.Context, id domain.OrderID) (*domain.Order, error) {
return nil, nil
}
func (m *mockOrderRepository) FindByCustomer(ctx context.Context, customerID domain.CustomerID) ([]*domain.Order, error) {
return nil, nil
}
// Mock adapter untuk InventoryChecker
type mockInventory struct {
available bool
}
func (m *mockInventory) CheckAvailability(ctx context.Context, items []domain.OrderItem) error {
if !m.available {
return errors.New("stok tidak cukup")
}
return nil
}
// Mock adapter untuk EventPublisher
type mockEventPublisher struct {
publishedEvents []domain.DomainEvent
}
func (m *mockEventPublisher) Publish(ctx context.Context, event domain.DomainEvent) error {
m.publishedEvents = append(m.publishedEvents, event)
return nil
}
// Test: menguji CreateOrderService tanpa database, HTTP, atau Kafka nyata
func TestCreateOrderService_Success(t *testing.T) {
repo := &mockOrderRepository{}
inventory := &mockInventory{available: true}
events := &mockEventPublisher{}
// ✓ Menggunakan mock adapter — tidak butuh PostgreSQL atau Kafka
svc := application.NewCreateOrderService(repo, inventory, events)
resp, err := svc.CreateOrder(context.Background(), inbound.CreateOrderRequest{
CustomerID: "cust-123",
Items: []inbound.CreateOrderItem{
{ProductID: "prod-1", Quantity: 2, PriceCents: 50000},
},
})
if err != nil {
t.Fatalf("tidak mengharapkan error, dapat: %v", err)
}
if resp.OrderID == "" {
t.Error("order ID tidak boleh kosong")
}
if resp.TotalCents != 100000 {
t.Errorf("total seharusnya 100000, dapat %d", resp.TotalCents)
}
if len(repo.savedOrders) != 1 {
t.Errorf("seharusnya 1 order tersimpan, dapat %d", len(repo.savedOrders))
}
if len(events.publishedEvents) == 0 {
t.Error("seharusnya ada event yang dipublikasikan")
}
}
func TestCreateOrderService_OutOfStock(t *testing.T) {
repo := &mockOrderRepository{}
inventory := &mockInventory{available: false} // ✓ simulasi stok habis
events := &mockEventPublisher{}
svc := application.NewCreateOrderService(repo, inventory, events)
_, err := svc.CreateOrder(context.Background(), inbound.CreateOrderRequest{
CustomerID: "cust-123",
Items: []inbound.CreateOrderItem{
{ProductID: "prod-1", Quantity: 999, PriceCents: 50000},
},
})
if err == nil {
t.Error("seharusnya error saat stok habis")
}
if len(repo.savedOrders) != 0 {
t.Error("order tidak boleh disimpan saat stok habis")
}
}
// Test menggunakan inbound adapter CLI langsung — tanpa HTTP server
func TestCreateOrder_ViaDirectPort(t *testing.T) {
// ✓ Test suite bisa memanggil inbound port langsung — tidak butuh HTTP server
var svc inbound.CreateOrderPort = application.NewCreateOrderService(
&mockOrderRepository{},
&mockInventory{available: true},
&mockEventPublisher{},
)
_, err := svc.CreateOrder(context.Background(), inbound.CreateOrderRequest{
CustomerID: "cust-456",
Items: []inbound.CreateOrderItem{
{ProductID: "prod-2", Quantity: 1, PriceCents: 75000},
},
})
if err != nil {
t.Fatalf("tidak mengharapkan error: %v", err)
}
}
Mengganti Adapter Tanpa Menyentuh Core #
Ini adalah demonstrasi paling kuat dari nilai Hexagonal Architecture — mengganti seluruh implementasi database hanya dengan mengganti adapter, tanpa menyentuh satu baris kode di domain atau application layer:
// Sebelum: menggunakan PostgreSQL
pgRepo := postgres.NewPostgresOrderRepository(postgresDB)
// Sesudah: migrasi ke MongoDB — hanya ganti adapter
// ✓ Application service tidak berubah sama sekali
mongoRepo := mongodb.NewMongoOrderRepository(mongoClient)
// Wiring di main.go — satu-satunya file yang berubah
svc := application.NewCreateOrderService(
mongoRepo, // ← ganti ini
inventoryChecker,
eventPublisher,
)
sequenceDiagram
participant HTTP as HTTP Adapter
participant PORT as Inbound Port
participant APP as Application Service
participant OUTPORT as Outbound Port
participant PG as PostgreSQL Adapter
HTTP->>PORT: CreateOrder(req)
PORT->>APP: CreateOrder(req)
APP->>OUTPORT: Save(order)
OUTPORT->>PG: INSERT INTO orders...
PG-->>OUTPORT: ok
OUTPORT-->>APP: nil
APP-->>PORT: CreateOrderResponse
PORT-->>HTTP: response
Note over OUTPORT,PG: Ganti PostgreSQL → MongoDB:<br/>hanya Adapter yang berubah
Perbandingan dengan Clean Architecture dan Onion #
Hexagonal, Clean, dan Onion Architecture adalah tiga nama untuk filosofi yang sangat mirip — ketiganya termasuk keluarga domain-centric architecture. Perbedaannya lebih pada penekanan dan terminologi:
| Aspek | Hexagonal | Clean Architecture | Onion Architecture |
|---|---|---|---|
| Metafora visual | Segi enam dengan port di sisi-sisinya | Lingkaran konsentris | Lapisan bawang |
| Terminologi kunci | Port dan Adapter | Layer dan Dependency Rule | Ring dan dependency |
| Penekanan | Kemampuan mengganti teknologi (pluggability) | Aturan arah dependency | Kemurnian domain di pusat |
| Inbound/Outbound | Eksplisit dibedakan | Tidak dibedakan secara formal | Tidak dibedakan |
| Diperkenalkan oleh | Alistair Cockburn (2005) | Robert C. Martin (2012) | Jeffrey Palermo (2008) |
Secara praktis, ketiganya menghasilkan kode yang sangat mirip saat diimplementasikan. Pilih terminologi yang paling mudah dikomunikasikan ke tim.
Anti-Pattern yang Harus Dihindari #
// ✗ Domain mengimport adapter — pelanggaran paling fundamental
package domain
import "myapp/adapter/outbound/postgres" // ✗ domain tidak boleh tahu tentang postgres
type OrderService struct {
repo *postgres.PostgresOrderRepository // ✗ dependency ke adapter konkret
}
// ✓ Domain hanya tahu tentang port (interface)
package domain
import "myapp/port/outbound" // ✓ hanya interface
type OrderService struct {
repo outbound.OrderRepository // ✓ dependency ke interface
}
// ✗ Business logic di adapter — adapter seharusnya hanya menerjemahkan
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
// ✗ validasi bisnis di HTTP handler
if req.Quantity > 100 {
http.Error(w, "quantity terlalu banyak", http.StatusBadRequest)
return
}
// ✗ kalkulasi harga di HTTP adapter
total := req.Quantity * req.Price * 0.9 // diskon 10%
// ...
}
// ✓ Adapter tipis — hanya parse, delegate, format
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req createOrderHTTPRequest
json.NewDecoder(r.Body).Decode(&req)
resp, err := h.createOrder.CreateOrder(r.Context(), mapToPortRequest(req)) // ✓
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
json.NewEncoder(w).Encode(resp)
}
// ✗ Outbound port didefinisikan di sisi adapter — pelanggaran ownership
// adapter/outbound/postgres/repository.go
package postgres
type OrderRepository interface { // ✗ interface di sisi implementasi
Save(order *Order) error
}
// ✓ Outbound port didefinisikan di sisi core — core yang menentukan kebutuhan
// port/outbound/repository.go
package outbound
type OrderRepository interface { // ✓ interface di sisi core
Save(ctx context.Context, order *domain.Order) error
}
Kapan Tidak Menggunakan Hexagonal Architecture #
Gunakan Hexagonal Architecture jika:
✓ Banyak integrasi eksternal yang mungkin diganti di masa depan
✓ Testability adalah prioritas tinggi — butuh unit test tanpa infrastruktur
✓ Tim perlu mengembangkan core dan adapter secara paralel
✓ Sistem berskala menengah hingga besar dengan umur panjang
✓ Ada kemungkinan menambah channel baru (REST + gRPC + CLI) tanpa ubah core
Pertimbangkan pendekatan lebih sederhana jika:
✗ Aplikasi sangat kecil dengan satu database dan satu channel
✗ Tim baru dalam interface dan dependency inversion
✗ Timeline sangat ketat — setup awal Hexagonal membutuhkan waktu lebih banyak
✗ Domain sangat sederhana dan tidak akan berubah
✗ Hanya ada satu implementasi untuk setiap port — interface tanpa alternatif
Checklist Review Hexagonal Architecture #
PORT DESIGN:
□ Inbound port didefinisikan di package application/port, bukan di adapter
□ Outbound port didefinisikan di package port/outbound, bukan di infrastructure
□ Port menggunakan domain object atau DTO — tidak menggunakan struct dari library eksternal
□ Inbound dan outbound port dipisahkan secara eksplisit
ADAPTER:
□ Inbound adapter hanya menerjemahkan format eksternal ke port — tidak ada business logic
□ Outbound adapter hanya mengimplementasikan port — tidak ada business logic
□ Nama adapter mencerminkan teknologinya: PostgresOrderRepository, KafkaEventPublisher
□ Setiap adapter memvalidasi interface dengan compile-time check: var _ Port = (*Impl)(nil)
APPLICATION CORE:
□ Tidak ada import dari package adapter atau infrastruktur
□ Semua dependency ke dunia luar melalui outbound port (interface)
□ Application service mengimplementasikan inbound port
□ Domain layer tidak punya dependency ke application atau adapter
PENGUJIAN:
□ Application service diuji dengan mock adapter — tidak butuh infrastruktur nyata
□ Domain logic diuji murni tanpa dependency apapun
□ Adapter diuji dengan integration test terhadap infrastruktur nyata
□ go test -race dijalankan untuk memastikan tidak ada race condition
DEPENDENCY INJECTION:
□ Wiring adapter ke port hanya di main.go
□ Tidak ada global state — semua dependency di-inject
□ Mudah mengganti adapter tanpa mengubah application core
Ringkasan #
- Port adalah kontrak, adapter adalah implementasi — port didefinisikan oleh core (apa yang dibutuhkan), adapter menyediakan implementasi konkret (bagaimana memenuhinya); keduanya harus dipisahkan secara tegas.
- Inbound port untuk actor yang memulai, outbound port untuk core yang memanggil — HTTP handler, gRPC server, dan CLI memanggil inbound port; database, email, dan message broker diakses melalui outbound port.
- Core tidak boleh tahu tentang adapter — jika ada
import "adapter/postgres"di dalam domain atau application layer, itu pelanggaran yang harus segera diperbaiki.- Adapter harus tipis — adapter hanya menerjemahkan format, tidak mengandung business logic; validasi bisnis ada di domain, orkestrasi ada di application service.
- Mengganti teknologi berarti mengganti adapter — migrasi database, ganti vendor email, tambah channel baru; semua hanya menyentuh adapter, tidak menyentuh core.
- Testability adalah hasil alami — mock adapter menggantikan implementasi konkret saat testing; test tidak butuh database nyata, HTTP server, atau message broker.
- Outbound port didefinisikan oleh core, bukan infrastruktur — ini sering terbalik pada implementasi yang salah; core menentukan kebutuhannya, infrastruktur memenuhinya.
- Compile-time check untuk interface — gunakan
var _ Port = (*Impl)(nil)untuk memastikan adapter benar-benar mengimplementasikan port yang diharapkan; error terdeteksi saat kompilasi, bukan saat runtime.- Hexagonal, Clean, dan Onion adalah keluarga yang sama — perbedaannya lebih pada terminologi; pilih yang paling mudah dikomunikasikan ke tim.
- Cocok untuk sistem dengan banyak integrasi — semakin banyak teknologi eksternal yang berinteraksi dengan sistem, semakin besar manfaat dari ketegasan port/adapter boundary.
← Sebelumnya: Domain-Driven Architecture Berikutnya: Onion Architecture →