Hexagonal Architecture (alias Ports & Adapters) membuat business logic tidak bergantung pada framework, database, atau UI. Logic inti (domain) berinteraksi dengan dunia luar melalui ports (interface) yang di-implement oleh adapters.
Diagram:
┌─────────── Adapters (luar) ───────────┐
│ │
HTTP ─┤┐ ┌─ PostgresUserRepo
││ │
CLI ──┤├─► PORTS (interface) ──► DOMAIN ◄──┤├── RedisCache
││ (kontrak, definisi) (core) ││
gRPC ─┤┘ └─ SendGridEmailSender
│ │
└────────────────────────────────────────┘
Inputs Outputs
Arah ketergantungan:
- Semua arrow menuju Domain core
- Domain TIDAK tahu tentang HTTP, Postgres, Redis, dll
- Domain hanya tahu port (interface)
- Adapter meng-implement port → detail eksternal tersembunyi
Contoh konkret:
// ─── PORT (interface, di dalam domain) ────────
// domain/ports/UserRepository.ts
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// ─── DOMAIN SERVICE ────────────────────────────
// domain/services/RegisterUserService.ts
class RegisterUserService {
constructor(private userRepo: UserRepository, // dependency pada port, bukan implementasi
private emailer: EmailSender) {}
async execute(email: string, password: string): Promise<User> {
// Pure business logic, tidak ada framework/DB detail
const existing = await this.userRepo.findById(email);
if (existing) throw new Error("Email taken");
const user = User.create(email, password);
await this.userRepo.save(user);
await this.emailer.sendWelcome(email);
return user;
}
}
// ─── ADAPTER (di luar domain) ──────────────────
// infra/PostgresUserRepository.ts
class PostgresUserRepository implements UserRepository {
constructor(private pool: Pool) {}
async findById(id: string): Promise<User | null> {
const row = await this.pool.query("SELECT * FROM users WHERE id=$1", [id]);
return row ? User.fromRow(row) : null;
}
async save(user: User): Promise<void> {
await this.pool.query("INSERT INTO users ...", [/* ... */]);
}
}
// ─── WIRING (di edge, boot time) ───────────────
const db = new Pool(dbConfig);
const userRepo = new PostgresUserRepository(db);
const emailer = new SendGridEmailSender(apiKey);
const service = new RegisterUserService(userRepo, emailer);
Keuntungan utama:
1. Testability:
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async findById(id) { return this.users.get(id) || null; }
async save(user) { this.users.set(user.id, user); }
}
// Test pakai in-memory, tanpa container Postgres
const service = new RegisterUserService(
new InMemoryUserRepository(),
new FakeEmailer()
);
2. Swap implementasi tanpa ubah domain:
Pindah dari Postgres ke DynamoDB? Tulis DynamoUserRepository, ubah wiring. Domain tidak disentuh.
3. Input flexibility:
REST API, CLI, scheduled job — semua bisa panggil service.execute(). Logika bisnis tidak duplicate per entry point.
Bandingkan dengan layered architecture tradisional:
TRADISIONAL (top-down):
Controller ──► Service ──► Repository ──► Database
(tiap lapisan tahu detail lapisan di bawah)
HEXAGONAL (inside-out):
Adapter ──► Port ◄──── Domain
(domain tidak tahu apa-apa tentang luar)
Aturan dasar:
- Domain ZERO framework import — tidak ada
import express,import mongoose - Port adalah interface dari sudut pandang domain — bukan sesuai database
- Adapter implement port — translate antara dunia luar dan domain
- Wiring di edge — main.ts / composition root, bukan di tengah-tengah
Kapan OVERKILL:
- Project < 100 files, tidak akan ganti database
- Team kecil, deadline pendek
- Prototype / proof of concept
Kapan PAYOFF besar:
- Codebase > 50k LOC
- Lifespan > 2 tahun
- Perlu test unit business logic tanpa infra
- Kemungkinan swap vendor/tech (DB, auth provider, payment gateway)
Related patterns:
- Clean Architecture (Uncle Bob) — variant lebih strict dengan lebih banyak layer
- Onion Architecture — serupa, fokus pada concentric layers
- DDD (Domain-Driven Design) — sering kombinasi dengan hexagonal