LA NINA // BLOG
Kembali ke Indeks Catatan

Filosofi Disiplin Rekayasa: Menegakkan Clean Architecture & Pola Ports & Adapters

Perangkat lunak yang tangguh di lingkungan produksi tidak lahir secara kebetulan; ia dikonstruksi di atas fondasi disiplin arsitektur yang ketat. Seringkali, tim rekayasa terjebak dalam framework-driven development, di mana logika bisnis esensial bercampur aduk dengan pustaka ORM, routing HTTP, atau pustaka pihak ketiga.

Ketika dependensi eksternal berubah atau mengalami deprecation, seluruh aplikasi runtuh akibat keterikatan erat (tight coupling).

Dokumen ini mengupas penerapan arsitektur heksagonal (Ports & Adapters Pattern) dan siklus ketat Test-Driven Development (TDD) dengan contoh implementasi terstruktur yang dapat diterapkan secara universal lintas bahasa pemrograman.


1. Anatomi Arsitektur Heksagonal (Ports & Adapters)

Tujuan utama dari arsitektur heksagonal yang digagas oleh Alistair Cockburn adalah memisahkan aturan bisnis (domain logic) dari mekanisme pengiriman (delivery mechanisms) dan infrastruktur penyimpanan (data persistence).

TEXT
┌─────────────────────────────────────────────────────────────┐
│                    Lapisan Luar (Adapters)                  │
│                                                             │
│   [ REST API / HTTP ]               [ Postgres / Redis ]    │
│           │                                   ▲             │
│   (Primary Adapter)                   (Secondary Adapter)   │
│           │                                   │             │
│           ▼                                   │             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │               Lapisan Kontrak (Ports)               │   │
│   │                                                     │   │
│   │   [ Inbound Port ]              [ Outbound Port ]   │   │
│   │   (Use-Case Interface)         (Repository Interface)│  │
│   │           │                               ▲         │   │
│   │           ▼                               │         │   │
│   │   ┌─────────────────────────────────────────────┐   │   │
│   │   │            Domain Core & Entities           │   │   │
│   │   │    - Validasi Aturan Bisnis Murni           │   │   │
│   │   │    - Bebas Dependensi Eksternal / Framework │   │   │
│   │   └─────────────────────────────────────────────┘   │   │
│   └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Prinsip Utama:

  1. Aturan Dependensi (Dependency Rule): Arah ketergantungan selalu mengalir ke dalam (inward). Lapisan inti domain tidak boleh mengetahui keberadaan database SQL, web server, atau pustaka serialisasi JSON.
  2. Inbound Port (Driving): Antarmuka yang mengekspos kemampuan use-case aplikasi ke dunia luar (misal: handler HTTP atau CLI memanggil port use-case).
  3. Outbound Port (Driven): Antarmuka yang mendefinisikan kebutuhan domain terhadap dunia luar (misal: antarmuka penyimpanan data atau antarmuka pengiriman notifikasi).

2. Implementasi Konkret: Domain Transaksi Eksekutif

Mari kita bedah implementasi praktis sistem pemrosesan transaksi menggunakan TypeScript murni tanpa framework apa pun:

Langkah A: Domain Entity & Business Invariants (Core)

Lapisan ini hanya berisi entitas murni dan fungsi validasi tanpa impor pustaka luar.

TYPESCRIPT
// domain/entities/transaction.ts
export interface TransactionProps {
  id: string;
  referenceCode: string;
  amount: number;
  currency: 'USD' | 'IDR' | 'EUR';
  status: 'PENDING' | 'SETTLED' | 'REJECTED';
  createdAt: Date;
}

export class Transaction {
  private constructor(private readonly props: TransactionProps) {
    this.validate();
  }

  public static create(props: Omit<TransactionProps, 'id' | 'createdAt' | 'status'> & { id?: string }): Transaction {
    return new Transaction({
      id: props.id || crypto.randomUUID(),
      referenceCode: props.referenceCode,
      amount: props.amount,
      currency: props.currency,
      status: 'PENDING',
      createdAt: new Date()
    });
  }

  private validate(): void {
    if (this.props.amount <= 0) {
      throw new Error("Nominal transaksi harus lebih besar dari nol.");
    }
    if (!this.props.referenceCode || this.props.referenceCode.trim().length < 6) {
      throw new Error("Kode referensi transaksi tidak valid.");
    }
  }

  public settle(): void {
    if (this.props.status !== 'PENDING') {
      throw new Error(`Tidak dapat menyelesaikan transaksi dengan status ${this.props.status}`);
    }
    this.props.status = 'SETTLED';
  }

  public toJSON(): Readonly<TransactionProps> {
    return Object.freeze({ ...this.props });
  }
}

Langkah B: Definisi Kontrak (Ports)

Port adalah antarmuka murni (interfaces) yang menjembatani komunikasi.

TYPESCRIPT
// ports/outbound/transaction-repository.port.ts
import { Transaction } from '../../domain/entities/transaction';

export interface TransactionRepositoryPort {
  save(transaction: Transaction): Promise<void>;
  findById(id: string): Promise<Transaction | null>;
  findByReferenceCode(code: string): Promise<Transaction | null>;
}

// ports/inbound/create-transaction.port.ts
export interface CreateTransactionDTO {
  referenceCode: string;
  amount: number;
  currency: 'USD' | 'IDR' | 'EUR';
}

export interface CreateTransactionUseCasePort {
  execute(dto: CreateTransactionDTO): Promise<{ transactionId: string; status: string }>;
}

Langkah C: Use-Case Orchestration (Application Service)

Use-case hanya berinteraksi dengan Inbound Port dan Outbound Port.

TYPESCRIPT
// application/use-cases/create-transaction.usecase.ts
import { CreateTransactionUseCasePort, CreateTransactionDTO } from '../../ports/inbound/create-transaction.port';
import { TransactionRepositoryPort } from '../../ports/outbound/transaction-repository.port';
import { Transaction } from '../../domain/entities/transaction';

export class CreateTransactionUseCase implements CreateTransactionUseCasePort {
  constructor(private readonly repository: TransactionRepositoryPort) {}

  async execute(dto: CreateTransactionDTO): Promise<{ transactionId: string; status: string }> {
    // 1. Cek idempotensi referensi transaksi
    const existing = await this.repository.findByReferenceCode(dto.referenceCode);
    if (existing) {
      throw new Error(`Transaksi dengan referensi ${dto.referenceCode} telah diproses.`);
    }

    // 2. Konstruksi entitas domain
    const transaction = Transaction.create({
      referenceCode: dto.referenceCode,
      amount: dto.amount,
      currency: dto.currency
    });

    // 3. Simpan state via outbound port
    await this.repository.save(transaction);

    const data = transaction.toJSON();
    return {
      transactionId: data.id,
      status: data.status
    };
  }
}

Langkah D: Implementasi Adapter (Infrastruktur)

Di sinilah detail teknis (seperti database atau HTTP router) berada.

TYPESCRIPT
// adapters/outbound/in-memory-transaction.repository.ts
import { TransactionRepositoryPort } from '../../ports/outbound/transaction-repository.port';
import { Transaction } from '../../domain/entities/transaction';

export class InMemoryTransactionRepository implements TransactionRepositoryPort {
  private readonly store = new Map<string, Transaction>();

  async save(transaction: Transaction): Promise<void> {
    this.store.set(transaction.toJSON().id, transaction);
  }

  async findById(id: string): Promise<Transaction | null> {
    return this.store.get(id) || null;
  }

  async findByReferenceCode(code: string): Promise<Transaction | null> {
    for (const tx of this.store.values()) {
      if (tx.toJSON().referenceCode === code) return tx;
    }
    return null;
  }
}

3. Disiplin Test-Driven Development (TDD)

Mengacu pada prinsip rekayasa deterministik: "Jangan pernah menyatakan pekerjaan selesai tanpa pembuktian tes otomatis yang terverifikasi."

Siklus TDD dijalankan dalam ritme Red-Green-Refactor:

  1. Fase Merah (RED): Tulis pengujian unit yang mengevaluasi skenario kegagalan dan ekspektasi bisnis sebelum kode logika selesai dibuat.
  2. Fase Hijau (GREEN): Tulis kode implementasi paling ringkas yang mampu meloloskan pengujian tersebut.
  3. Fase Rekayasa Ulang (REFACTOR): Rapikan struktur, hilangkan duplikasi, dan perbaiki penamaan tanpa merusak hasil uji.

Contoh Suite Pengujian Unit TDD:

TYPESCRIPT
// tests/use-cases/create-transaction.spec.ts
import { CreateTransactionUseCase } from '../../application/use-cases/create-transaction.usecase';
import { InMemoryTransactionRepository } from '../../adapters/outbound/in-memory-transaction.repository';

describe('CreateTransactionUseCase (Unit Test TDD)', () => {
  let repository: InMemoryTransactionRepository;
  let useCase: CreateTransactionUseCase;

  beforeEach(() => {
    repository = new InMemoryTransactionRepository();
    useCase = new CreateTransactionUseCase(repository);
  });

  it('harus berhasil membuat transaksi baru jika data valid', async () => {
    const result = await useCase.execute({
      referenceCode: 'TX-ORD-2026-99',
      amount: 150000,
      currency: 'IDR'
    });

    expect(result.transactionId).toBeDefined();
    expect(result.status).toBe('PENDING');

    const saved = await repository.findById(result.transactionId);
    expect(saved).not.toBeNull();
    expect(saved?.toJSON().amount).toBe(150000);
  });

  it('harus menolak transaksi dengan nominal negatif atau nol', async () => {
    await expect(useCase.execute({
      referenceCode: 'TX-ORD-INVALID',
      amount: -500,
      currency: 'USD'
    })).rejects.toThrow("Nominal transaksi harus lebih besar dari nol.");
  });

  it('harus menolak transaksi duplikat dengan kode referensi yang sama (Idempotency)', async () => {
    await useCase.execute({
      referenceCode: 'TX-DUP-001',
      amount: 75000,
      currency: 'IDR'
    });

    await expect(useCase.execute({
      referenceCode: 'TX-DUP-001',
      amount: 80000,
      currency: 'IDR'
    })).rejects.toThrow("telah diproses");
  });
});

4. Keuntungan Jangka Panjang

Menerapkan arsitektur heksagonal dan TDD memberikan proteksi penuh terhadap degradasi sistem:

  • Pengujian Kilat: Tes unit use-case dan domain berjalan dalam hitungan milidetik karena tidak memerlukan database asli atau koneksi jaringan aktif.
  • Agilitas Migrasi: Mengganti layer database dari relational database ke document database hanya membutuhkan pembuatan satu adapter baru tanpa mengubah logika use-case.
  • Bebas Utang Teknis: Kompleksitas bisnis terkapsulasi sempurna, memudahkan pemeliharaan jangka panjang dan onboarding insinyur baru.
Kembali ke Atas ↑