ARCHITECTURE DESIGN-PATTERNS SOFTWARE-ARCHITECTURE BACKEND TESTABILITY MAINTAINABILITY DECOUPLING CLEAN-ARCHITECTURE SYSTEM-DESIGN

Membangun Aplikasi Fleksibel dan Tahan Uji: Menggali Arsitektur Heksagonal (Ports and Adapters)

⏱️ 13 menit baca
👨‍💻

Membangun Aplikasi Fleksibel dan Tahan Uji: Menggali Arsitektur Heksagonal (Ports and Adapters)

1. Pendahuluan

Pernahkah Anda merasa terjebak dalam kode yang sulit diubah, sulit diuji, atau terlalu terikat dengan satu teknologi database atau framework tertentu? Jika ya, Anda tidak sendirian. Banyak developer mengalami “framework lock-in” atau “database lock-in,” di mana logika bisnis inti aplikasi bercampur aduk dengan detail implementasi infrastruktur. Akibatnya, setiap kali ada perubahan kecil pada database atau framework, seluruh bagian aplikasi bisa terpengaruh, membuat pengembangan menjadi lambat dan penuh risiko.

Di sinilah Arsitektur Heksagonal, juga dikenal sebagai Ports and Adapters Architecture, datang sebagai penyelamat. Dirancang oleh Alistair Cockburn, arsitektur ini bertujuan untuk menciptakan pemisahan yang jelas antara logika bisnis inti aplikasi (Domain Core) dengan dunia eksternal (database, UI, API pihak ketiga, dll.). Bayangkan aplikasi Anda sebagai sebuah heksagon (segi enam) yang memiliki banyak “pintu” (ports) untuk berkomunikasi dengan dunia luar, dan setiap pintu tersebut memiliki “penerjemah” (adapters) yang tahu cara berbicara dengan teknologi spesifik di luarnya.

🎯 Tujuan utama artikel ini adalah:

Mari kita selami lebih dalam!

2. Memahami Konsep Inti: Heksagon, Ports, dan Adapters

Arsitektur Heksagonal berpusat pada ide bahwa inti aplikasi Anda harus benar-benar terisolasi dan tidak peduli dengan bagaimana ia berkomunikasi dengan dunia luar. Ini dicapai melalui tiga komponen utama:

a. The Hexagon (Domain Core)

Ini adalah jantung aplikasi Anda. Di sinilah semua logika bisnis, aturan, dan entitas domain berada. Heksagon ini adalah “agnostik teknologi” — ia tidak tahu apakah data disimpan di PostgreSQL, MongoDB, atau apakah ia diakses melalui REST API, GraphQL, atau CLI. Ia hanya tahu tentang apa yang harus dilakukan, bukan bagaimana melakukannya.

b. Ports

Ports adalah “antarmuka” atau “kontrak” yang mendefinisikan cara Heksagon berinteraksi dengan dunia luar. Ada dua jenis Ports:

  1. Driving Ports (Primary Ports): Ini adalah antarmuka yang digunakan oleh dunia luar untuk meminta Heksagon melakukan sesuatu. Contohnya, jika Anda ingin membuat user baru, Heksagon akan mengekspos sebuah port (misalnya, UserServicePort) dengan method createUser(user: User). Dunia luar (misalnya, REST API) akan “mendorong” permintaan melalui port ini.
  2. Driven Ports (Secondary Ports): Ini adalah antarmuka yang digunakan oleh Heksagon untuk meminta layanan dari dunia luar. Contohnya, Heksagon perlu menyimpan data user. Ia akan mengekspos sebuah port (misalnya, UserRepositoryPort) dengan method save(user: User). Heksagon “mendorong” permintaan melalui port ini ke dunia luar (misalnya, database).

📌 Ingat: Ports hanyalah antarmuka (interface). Mereka tidak memiliki implementasi konkret.

c. Adapters

Adapters adalah implementasi konkret dari Ports. Mereka adalah “penerjemah” yang menghubungkan Heksagon dengan teknologi spesifik di dunia luar.

  1. Driving Adapters: Ini adalah adapter yang “mendorong” interaksi ke Heksagon melalui Driving Ports. Contoh:
    • REST API Adapter: Menerima request HTTP, menerjemahkannya ke format yang dipahami Heksagon, lalu memanggil Driving Port.
    • CLI Adapter: Menerima input dari command line, menerjemahkannya, lalu memanggil Driving Port.
    • GUI Adapter: Menerima input dari UI, menerjemahkannya, lalu memanggil Driving Port.
  2. Driven Adapters: Ini adalah adapter yang “didorong” oleh Heksagon melalui Driven Ports untuk melakukan sesuatu di dunia luar. Contoh:
    • Database Adapter (SQL/NoSQL): Mengimplementasikan UserRepositoryPort untuk menyimpan dan mengambil data user dari database spesifik (misalnya, PostgreSQLUserRepositoryAdapter, MongoUserRepositoryAdapter).
    • Third-Party API Adapter: Mengimplementasikan port untuk berinteraksi dengan API eksternal (misalnya, PaymentGatewayAdapter).
    • Message Broker Adapter: Mengimplementasikan port untuk mengirim atau menerima pesan dari Message Queue (misalnya, RabbitMQEventPublisherAdapter).

💡 Analogi: Bayangkan Anda adalah seorang koki (Heksagon) yang sangat ahli. Anda tahu cara membuat berbagai hidangan lezat (logika bisnis). Anda memiliki buku resep (Ports) yang berisi instruksi umum (misalnya, “ambil bahan X,” “masak dengan cara Y”). Anda tidak peduli apakah bahan X dibeli dari pasar tradisional atau supermarket online. Yang penting, ada asisten (Adapters) yang bisa menerjemahkan resep Anda ke tindakan nyata, seperti pergi ke pasar, membeli bahan, dan menyiapkannya sesuai instruksi Anda. Jika besok Anda ingin belanja di supermarket lain, Anda hanya perlu mengganti asisten (Adapter) tanpa mengubah resep Anda (Heksagon).

3. Manfaat Arsitektur Heksagonal

Mengadopsi Arsitektur Heksagonal membawa beberapa keuntungan signifikan:

a. Kemampuan Uji (Testability) yang Luar Biasa ✅

Karena Heksagon benar-benar terisolasi dari detail infrastruktur, Anda bisa menguji logika bisnis inti tanpa perlu database sungguhan, API eksternal, atau bahkan UI. Anda bisa mengganti Adapters yang sesungguhnya dengan implementasi “mock” atau “stub” selama pengujian. Ini membuat unit testing dan integration testing jauh lebih cepat, mudah, dan andal.

// Contoh Driving Port (di Domain Core)
interface CreateUserPort {
    execute(command: CreateUserCommand): User;
}

// Contoh Driven Port (di Domain Core)
interface UserRepositoryPort {
    save(user: User): User;
    findById(id: string): User | null;
}

// Di test, kita bisa mock UserRepositoryPort
class MockUserRepositoryAdapter implements UserRepositoryPort {
    private users: User[] = [];
    save(user: User): User {
        this.users.push(user);
        return user;
    }
    findById(id: string): User | null {
        return this.users.find(u => u.id === id) || null;
    }
}

// Saat menguji CreateUserService (bagian dari Heksagon)
// Kita bisa inject MockUserRepositoryAdapter
const mockRepo = new MockUserRepositoryAdapter();
const userService = new CreateUserService(mockRepo);
const newUser = userService.execute({ name: "John Doe", email: "john@example.com" });
// ... lakukan assertion

b. Fleksibilitas dan Kemudahan Perubahan 🔄

Perlu beralih dari PostgreSQL ke MongoDB? Atau dari REST API ke gRPC? Dengan Arsitektur Heksagonal, Anda hanya perlu menulis Adapter baru untuk teknologi tersebut dan menggantinya. Logika bisnis inti Anda tetap tidak tersentuh. Ini sangat mengurangi biaya dan risiko perubahan.

c. Agnostik Teknologi (Technology Agnostic) 🌐

Heksagon tidak peduli dengan teknologi yang digunakan di luar dirinya. Ini berarti Anda bisa memilih teknologi terbaik untuk setiap bagian aplikasi tanpa mengikat seluruh sistem padanya. Frontend bisa React, backend bisa Node.js, database bisa PostgreSQL, dan sistem notifikasi bisa Kafka, semua berinteraksi melalui Ports dan Adapters.

d. Pemisahan Kekhawatiran (Separation of Concerns) 🧩

Setiap komponen memiliki tanggung jawab yang jelas. Heksagon fokus pada bisnis, Ports mendefinisikan kontrak, dan Adapters menangani detail implementasi teknologi. Ini membuat kode lebih bersih, lebih mudah dipahami, dan lebih mudah dikelola.

4. Struktur Proyek Arsitektur Heksagonal (Contoh)

Bagaimana kita bisa mengatur folder dan file untuk merefleksikan arsitektur ini? Berikut adalah contoh struktur dasar:

src/
├── domain/                  // The Hexagon / Domain Core
│   ├── model/               // Entitas bisnis (User, Product, Order)
│   │   └── User.ts
│   ├── ports/               // Interfaces (Driving & Driven Ports)
│   │   ├── in/              // Driving Ports (e.g., UserServicePort)
│   │   │   └── CreateUserPort.ts
│   │   ├── out/             // Driven Ports (e.g., UserRepositoryPort, NotificationServicePort)
│   │   │   └── UserRepositoryPort.ts
│   │   └── index.ts
│   └── services/            // Implementasi logika bisnis inti (Core Services)
│       └── CreateUserService.ts
│       └── ...
│   └── index.ts
├── infrastructure/          // Adapters & detail implementasi eksternal
│   ├── persistence/         // Driven Adapters untuk database
│   │   ├── repositories/
│   │   │   ├── PostgresUserRepositoryAdapter.ts // Implementasi UserRepositoryPort
│   │   │   └── MongoUserRepositoryAdapter.ts
│   │   └── entities/        // DTOs/Entities untuk database (opsional, jika beda dengan domain model)
│   │       └── UserEntity.ts
│   ├── controllers/         // Driving Adapters untuk API (REST, GraphQL, gRPC)
│   │   ├── rest/
│   │   │   └── UserController.ts // Menggunakan CreateUserPort
│   │   ├── cli/
│   │   │   └── UserCLIAdapter.ts
│   │   └── ...
│   ├── messaging/           // Driven Adapters untuk message brokers
│   │   └── RabbitMQNotificationAdapter.ts // Implementasi NotificationServicePort
│   └── config/              // Konfigurasi aplikasi
│       └── app.ts
└── app.ts                   // Entry point, mengikat Ports dan Adapters

Dalam struktur ini:

5. Contoh Konkret: Aplikasi Manajemen User Sederhana

Mari kita ilustrasikan dengan contoh sederhana: membuat user baru.

a. Domain Core (Heksagon)

// src/domain/model/User.ts
export class User {
  readonly id: string;
  readonly name: string;
  readonly email: string;

  constructor(id: string, name: string, email: string) {
    this.id = id;
    this.name = name;
    this.email = email;
  }

  // Metode bisnis terkait User
  changeName(newName: string): User {
    return new User(this.id, newName, this.email);
  }
}

// src/domain/ports/in/CreateUserPort.ts (Driving Port)
export interface CreateUserCommand {
  name: string;
  email: string;
}

export interface CreateUserPort {
  execute(command: CreateUserCommand): User;
}

// src/domain/ports/out/UserRepositoryPort.ts (Driven Port)
export interface UserRepositoryPort {
  save(user: User): User;
  findById(id: string): User | null;
}

// src/domain/services/CreateUserService.ts (Logika Bisnis Inti)
import { v4 as uuidv4 } from "uuid"; // Untuk generate ID
import { User } from "../model/User";
import { CreateUserPort, CreateUserCommand } from "../ports/in/CreateUserPort";
import { UserRepositoryPort } from "../ports/out/UserRepositoryPort";

export class CreateUserService implements CreateUserPort {
  private readonly userRepository: UserRepositoryPort;

  constructor(userRepository: UserRepositoryPort) {
    this.userRepository = userRepository;
  }

  execute(command: CreateUserCommand): User {
    // ✅ Logika bisnis: validasi email, generate ID, dll.
    if (!command.email.includes("@")) {
      throw new Error("Invalid email format");
    }

    const newUser = new User(uuidv4(), command.name, command.email);
    return this.userRepository.save(newUser); // Heksagon meminta adapter untuk menyimpan
  }
}

Perhatikan bahwa CreateUserService hanya bergantung pada UserRepositoryPort (interface), bukan implementasi konkret database.

b. Infrastructure (Adapters)

// src/infrastructure/persistence/repositories/PostgresUserRepositoryAdapter.ts (Driven Adapter)
import { User } from "../../../domain/model/User";
import { UserRepositoryPort } from "../../../domain/ports/out/UserRepositoryPort";
// Asumsikan ada koneksi database 'db'
// import { db } from '../../config/database';

export class PostgresUserRepositoryAdapter implements UserRepositoryPort {
  save(user: User): User {
    console.log(`[Postgres] Saving user: ${user.name} (${user.email})`);
    // ⚠️ Di sini akan ada kode SQL untuk menyimpan ke PostgreSQL
    // const result = await db.query('INSERT INTO users (...) VALUES (...)', [user.id, user.name, user.email]);
    return user; // Atau kembalikan user dengan ID dari DB jika auto-generated
  }

  findById(id: string): User | null {
    console.log(`[Postgres] Finding user by ID: ${id}`);
    // ⚠️ Di sini akan ada kode SQL untuk mengambil dari PostgreSQL
    // const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    // return row ? new User(row.id, row.name, row.email) : null;
    return null; // Placeholder
  }
}

// src/infrastructure/controllers/rest/UserController.ts (Driving Adapter)
import { Request, Response } from "express"; // Contoh dengan Express.js
import {
  CreateUserPort,
  CreateUserCommand,
} from "../../../domain/ports/in/CreateUserPort";

export class UserController {
  private readonly createUserPort: CreateUserPort;

  constructor(createUserPort: CreateUserPort) {
    this.createUserPort = createUserPort;
  }

  async create(req: Request, res: Response): Promise<void> {
    try {
      const command: CreateUserCommand = {
        name: req.body.name,
        email: req.body.email,
      };
      const user = this.createUserPort.execute(command); // Driving Adapter memanggil Driving Port
      res.status(201).json(user);
    } catch (error: any) {
      res.status(400).json({ message: error.message });
    }
  }
}

c. Entry Point (Mengikat Semuanya)

// src/app.ts
import express from "express";
import { CreateUserService } from "./domain/services/CreateUserService";
import { PostgresUserRepositoryAdapter } from "./infrastructure/persistence/repositories/PostgresUserRepositoryAdapter";
import { UserController } from "./infrastructure/controllers/rest/UserController";

const app = express();
app.use(express.json());

// 📌 Dependency Injection: Mengikat Ports dengan Adapters
const userRepositoryAdapter = new PostgresUserRepositoryAdapter(); // Ini adalah Driven Adapter
const createUserService = new CreateUserService(userRepositoryAdapter); // Ini adalah Service inti yang butuh Driven Port
const userController = new UserController(createUserService); // Ini adalah Driving Adapter yang butuh Driving Port

// ✅ Konfigurasi rute API
app.post("/users", (req, res) => userController.create(req, res));

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Dengan cara ini, CreateUserService tidak tahu menahu tentang Express.js atau PostgreSQL. Ia hanya tahu ada UserRepositoryPort yang bisa menyimpan User. Fleksibel, kan?

6. Kapan Menggunakan Arsitektur Heksagonal?

Arsitektur Heksagonal bukan solusi untuk semua masalah. Ini paling cocok untuk:

Kapan mungkin tidak perlu? Untuk aplikasi CRUD sederhana yang jarang berubah dan tidak memiliki logika bisnis yang rumit, arsitektur yang lebih sederhana mungkin lebih efisien di awal. Namun, perlu diingat bahwa aplikasi sederhana pun bisa tumbuh kompleks seiring waktu.

Kesimpulan

Arsitektur Heksagonal (Ports and Adapters) adalah pola desain arsitektur yang kuat untuk membangun aplikasi yang bersih, fleksibel, dan tahan uji. Dengan secara tegas memisahkan logika bisnis inti dari detail infrastruktur eksternal, Anda akan mendapatkan sistem yang lebih mudah dikelola, diubah, dan diskalakan.

Memang, ada sedikit kurva pembelajaran dan overhead awal dalam pengaturan struktur proyek. Namun, investasi ini akan terbayar lunas dalam jangka panjang, terutama saat aplikasi Anda berkembang dan kebutuhan untuk beradaptasi dengan perubahan teknologi semakin meningkat.

Jadi, jika Anda ingin membangun aplikasi yang tidak hanya berfungsi, tetapi juga tangguh dan siap untuk masa depan, pertimbangkan untuk menerapkan Arsitektur Heksagonal dalam proyek Anda selanjutnya!

🔗 Baca Juga