Ports & Adapters (Hexagonal Architecture) — Design Patterns

Hexagonal Architecture (alias Ports & Adapters) membuat business logic tidak bergantung pada framework, database, atau UI. Logic inti (domain) berinteraksi deng

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:

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:

  1. Domain ZERO framework import — tidak ada import express, import mongoose
  2. Port adalah interface dari sudut pandang domain — bukan sesuai database
  3. Adapter implement port — translate antara dunia luar dan domain
  4. Wiring di edge — main.ts / composition root, bukan di tengah-tengah

Kapan OVERKILL:

Kapan PAYOFF besar:

Related patterns: