DOMAIN-DRIVEN-DESIGN DESIGN-PATTERNS EVENT-DRIVEN CLEAN-ARCHITECTURE MICROSERVICES SOFTWARE-ARCHITECTURE BACKEND-DEVELOPMENT REFACTORING TESTABILITY DECOUPLING

Domain Event Pattern: Menggali Kekuatan Event Bisnis untuk Aplikasi yang Bersih dan Fleksibel

⏱️ 11 menit baca
👨‍💻

Domain Event Pattern: Menggali Kekuatan Event Bisnis untuk Aplikasi yang Bersih dan Fleksibel

1. Pendahuluan

Pernahkah kamu merasa kode aplikasi webmu mulai kusut? Setiap kali ada perubahan kecil, efeknya merambat ke mana-mana. Logika bisnis bercampur aduk dengan efek samping seperti mengirim email, update notifikasi, atau memicu proses lain. Ini adalah tanda-tanda tight coupling atau ketergantungan yang erat antar bagian kode.

Di sinilah Domain Event Pattern hadir sebagai penyelamat! Pattern ini menawarkan cara elegan untuk memisahkan logika bisnis inti dari efek samping yang ditimbulkannya. Bayangkan sebuah sistem di mana setiap “sesuatu yang penting terjadi” dalam domain bisnismu bisa diumumkan, dan bagian lain dari sistem bisa “mendengarkan” dan bereaksi tanpa perlu tahu siapa yang mengumumkannya. Ini tidak hanya membuat kode lebih bersih, tapi juga lebih mudah diuji, diskalakan, dan yang terpenting, lebih fleksibel terhadap perubahan di masa depan.

Dalam artikel ini, kita akan menyelami apa itu Domain Event Pattern, mengapa ia begitu powerful, bagaimana cara mengimplementasikannya, dan tips praktis untuk menerapkannya di aplikasi webmu. Mari kita mulai perjalanan ini!

2. Apa Itu Domain Event?

📌 Domain Event adalah sesuatu yang terjadi di dalam domain bisnismu yang relevan bagi bagian lain dari sistem. Ini adalah fakta tentang masa lalu, sesuatu yang sudah terjadi.

💡 Analogi paling mudah adalah seperti koran atau pengumuman di suatu komunitas. Misalnya, di sebuah toko online:

Setiap event ini adalah fakta yang tidak bisa dibantah. Setelah “Pesanan Baru Dibuat”, kamu tidak bisa mengubah fakta itu. Yang bisa kamu lakukan adalah menindaklanjuti atau bereaksi terhadap fakta tersebut.

Tujuan utama dari Domain Event adalah untuk:

  1. Mencatat perubahan penting: Ini membantu dalam auditabilitas dan pemahaman alur bisnis.
  2. Memicu efek samping: Tanpa perlu coupling langsung ke logika inti. Misalnya, saat “Pesanan Baru Dibuat”, kita perlu mengirim email konfirmasi, mengurangi stok barang, dan mencatatnya di log pengiriman. Semua ini adalah efek samping dari event tersebut.

3. Kenapa Menggunakan Domain Event Pattern?

Mengapa repot-repot memperkenalkan Domain Event? Bukankah kita bisa langsung memanggil fungsi sendEmail() atau updateStock() setelah createOrder()? Tentu bisa, tapi ini akan menyebabkan masalah:

4. Anatomi Domain Event

Sebuah Domain Event idealnya adalah objek data sederhana yang immutable (tidak bisa diubah setelah dibuat) dan berisi informasi yang relevan tentang event yang terjadi.

Elemen-elemen penting dalam Domain Event:

Contoh dalam TypeScript:

// interfaces/domain-events.ts
export interface IDomainEvent {
  readonly timestamp: Date;
  readonly eventId: string;
  readonly aggregateId?: string; // Jika event berasal dari agregat
  readonly eventName: string;
}

export class OrderCreatedEvent implements IDomainEvent {
  readonly timestamp: Date;
  readonly eventId: string;
  readonly eventName: string = "OrderCreated";

  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly totalAmount: number,
    public readonly items: Array<{ productId: string; quantity: number }>
  ) {
    this.timestamp = new Date();
    this.eventId = crypto.randomUUID(); // Atau UUID generator lainnya
  }
}

export class PaymentSucceededEvent implements IDomainEvent {
  readonly timestamp: Date;
  readonly eventId: string;
  readonly eventName: string = "PaymentSucceeded";

  constructor(
    public readonly paymentId: string,
    public readonly orderId: string,
    public readonly amount: number
  ) {
    this.timestamp = new Date();
    this.eventId = crypto.randomUUID();
  }
}

5. Implementasi Domain Event Pattern

Implementasi Domain Event Pattern melibatkan tiga komponen utama:

  1. Domain Entity/Aggregate: Objek yang memancarkan event.
  2. Domain Event Dispatcher: Mekanisme untuk mengumpulkan dan memancarkan event.
  3. Domain Event Handler: Komponen yang mendengarkan event dan melakukan efek samping.

Mari kita lihat contoh sederhana menggunakan Node.js/TypeScript.

a. Domain Entity/Aggregate yang Memancarkan Event

Biasanya, sebuah aggregate (konsep dari DDD) atau entity akan memancarkan event ketika state-nya berubah secara signifikan. Event ini tidak langsung diproses, melainkan dikumpulkan.

// domain/order.ts
import { OrderCreatedEvent, PaymentSucceededEvent } from '../interfaces/domain-events';

export class Order {
  private id: string;
  private userId: string;
  private totalAmount: number;
  private status: 'pending' | 'paid' | 'shipped';
  private items: Array<{ productId: string; quantity: number }>;
  private domainEvents: IDomainEvent[] = []; // Tempat mengumpulkan event

  constructor(id: string, userId: string, totalAmount: number, items: Array<{ productId: string; quantity: number }>) {
    this.id = id;
    this.userId = userId;
    this.totalAmount = totalAmount;
    this.items = items;
    this.status = 'pending';
    this.addDomainEvent(new OrderCreatedEvent(id, userId, totalAmount, items));
  }

  markAsPaid(paymentId: string, amount: number): void {
    if (this.status === 'pending' && amount >= this.totalAmount) {
      this.status = 'paid';
      this.addDomainEvent(new PaymentSucceededEvent(paymentId, this.id, amount));
    } else {
      throw new Error('Order cannot be marked as paid.');
    }
  }

  // Metode untuk menambahkan event ke dalam daftar
  private addDomainEvent(event: IDomainEvent): void {
    this.domainEvents.push(event);
  }

  // Metode untuk mendapatkan dan menghapus semua event yang terkumpul
  public getAndClearDomainEvents(): IDomainEvent[] {
    const events = [...this.domainEvents];
    this.domainEvents = [];
    return events;
  }

  public getId(): string { return this.id; }
  public getUserId(): string { return this.userId; }
  public getTotalAmount(): number { return this.totalAmount; }
  public getStatus(): string { return this.status; }
  public getItems(): Array<{ productId: string; quantity: number }> { return this.items; }
}

Perhatikan bagaimana Order hanya mengumpulkan event, tidak memprosesnya langsung. Ini penting!

b. Domain Event Dispatcher

Dispatcher adalah komponen yang bertanggung jawab untuk memancarkan event yang terkumpul ke handler yang sesuai. Dispatcher ini biasanya dipanggil setelah transaksi database berhasil disimpan, untuk memastikan konsistensi data.

// infrastructure/event-dispatcher.ts
import { IDomainEvent } from '../interfaces/domain-events';

// Interface untuk handler
export interface IDomainEventHandler<T extends IDomainEvent> {
  handle(event: T): Promise<void>;
}

// Map untuk menyimpan registrasi handler
const handlers = new Map<string, IDomainEventHandler<IDomainEvent>[]>();

export class DomainEventDispatcher {
  public static register<T extends IDomainEvent>(
    eventName: string,
    handler: IDomainEventHandler<T>
  ): void {
    if (!handlers.has(eventName)) {
      handlers.set(eventName, []);
    }
    handlers.get(eventName)?.push(handler as IDomainEventHandler<IDomainEvent>);
    console.log(`Handler registered for event: ${eventName}`);
  }

  public static async dispatch(event: IDomainEvent): Promise<void> {
    const eventHandlers = handlers.get(event.eventName);
    if (eventHandlers) {
      console.log(`Dispatching event: ${event.eventName} (ID: ${event.eventId})`);
      for (const handler of eventHandlers) {
        try {
          await handler.handle(event);
        } catch (error) {
          console.error(`Error handling event ${event.eventName} with handler ${handler.constructor.name}:`, error);
          // ⚠️ Penting: Pertimbangkan strategi retry atau dead-letter queue di sini
        }
      }
    } else {
      console.warn(`No handlers registered for event: ${event.eventName}`);
    }
  }

  public static async dispatchAll(events: IDomainEvent[]): Promise<void> {
    for (const event of events) {
      await this.dispatch(event);
    }
  }
}

c. Domain Event Handler

Handler adalah logika bisnis yang bereaksi terhadap event. Setiap handler harus fokus pada satu tanggung jawab saja (Single Responsibility Principle).

// application/handlers/send-email-on-order-created.ts
import { IDomainEventHandler, IDomainEvent } from '../../infrastructure/event-dispatcher';
import { OrderCreatedEvent } from '../../interfaces/domain-events';

class SendEmailOnOrderCreatedHandler implements IDomainEventHandler<OrderCreatedEvent> {
  async handle(event: OrderCreatedEvent): Promise<void> {
    console.log(`[Email Service] Mengirim email konfirmasi untuk Order ID: ${event.orderId} ke User ID: ${event.userId}`);
    // Logika pengiriman email sesungguhnya (misal: panggil SendGrid, Mailgun, dll.)
    await new Promise(resolve => setTimeout(resolve, 50)); // Simulasikan async
    console.log(`[Email Service] Email terkirim untuk Order ID: ${event.orderId}`);
  }
}

// application/handlers/update-inventory-on-order-created.ts
import { IDomainEventHandler, IDomainEvent } from '../../infrastructure/event-dispatcher';
import { OrderCreatedEvent } from '../../interfaces/domain-events';

class UpdateInventoryOnOrderCreatedHandler implements IDomainEventHandler<OrderCreatedEvent> {
  async handle(event: OrderCreatedEvent): Promise<void> {
    console.log(`[Inventory Service] Mengurangi stok untuk Order ID: ${event.orderId}`);
    // Logika pengurangan stok (misal: panggil Inventory Microservice atau update DB)
    for (const item of event.items) {
      console.log(`  - Mengurangi ${item.quantity} unit untuk Product ID: ${item.productId}`);
    }
    await new Promise(resolve => setTimeout(resolve, 100)); // Simulasikan async
    console.log(`[Inventory Service] Stok berhasil diperbarui untuk Order ID: ${event.orderId}`);
  }
}

// application/handlers/log-payment-on-succeeded.ts
import { IDomainEventHandler, IDomainEvent } from '../../infrastructure/event-dispatcher';
import { PaymentSucceededEvent } from '../../interfaces/domain-events';

class LogPaymentOnSucceededHandler implements IDomainEventHandler<PaymentSucceededEvent> {
  async handle(event: PaymentSucceededEvent): Promise<void> {
    console.log(`[Reporting Service] Mencatat pembayaran berhasil: Payment ID: ${event.paymentId}, Order ID: ${event.orderId}, Amount: ${event.amount}`);
    // Logika pencatatan ke database lain atau sistem reporting
    await new Promise(resolve => setTimeout(resolve, 20)); // Simulasikan async
    console.log(`[Reporting Service] Pembayaran berhasil dicatat.`);
  }
}

// Daftarkan handler saat aplikasi dimulai
// app.ts atau main.ts
import { DomainEventDispatcher } from './infrastructure/event-dispatcher';

export const sendEmailHandler = new SendEmailOnOrderCreatedHandler();
export const updateInventoryHandler = new UpdateInventoryOnOrderCreatedHandler();
export const logPaymentHandler = new LogPaymentOnSucceededHandler();

DomainEventDispatcher.register(sendEmailHandler.constructor.name, sendEmailHandler); // Ini salah, harusnya eventName
DomainEventDispatcher.register("OrderCreated", sendEmailHandler);
DomainEventDispatcher.register("OrderCreated", updateInventoryHandler);
DomainEventDispatcher.register("PaymentSucceeded", logPaymentHandler);

console.log("Handlers registered successfully.");

d. Integrasi dengan Unit of Work (Transactional Outbox)

⚠️ Penting: Domain event harus dipancarkan setelah semua perubahan state di database telah berhasil disimpan (transaksi commit). Jika event dipancarkan sebelum commit dan transaksi gagal, event akan terkirim padahal state tidak berubah, menyebabkan inkonsistensi.

Untuk menjamin konsistensi ini, kita bisa menggunakan pola Unit of Work dan Transactional Outbox.

// services/order-service.ts (Contoh penggunaan)
import { Order } from '../domain/order';
import { DomainEventDispatcher } from '../infrastructure/event-dispatcher';

export class OrderService {
  async createNewOrder(userId: string, items: Array<{ productId: string; quantity: number }>): Promise<Order> {
    const orderId = `ORD-${Date.now()}`; // Contoh ID
    const totalAmount = items.reduce((sum, item) => sum + 10 * item.quantity, 0); // Contoh harga

    // 1. Buat agregat Order, yang akan memancarkan OrderCreatedEvent secara internal
    const order = new Order(orderId, userId, totalAmount, items);

    // 2. Simpan order ke database (ini akan menjadi bagian dari transaksi)
    // Misalnya: await this.orderRepository.save(order);
    console.log(`[Repository] Order ID: ${order.getId()} disimpan ke database. Status: ${order.getStatus()}`);

    // 3. Setelah transaksi database berhasil, ambil dan pancarkan event
    const domainEvents = order.getAndClearDomainEvents();
    await DomainEventDispatcher.dispatchAll(domainEvents); // Dalam skenario nyata, ini bisa ke Outbox

    return order;
  }

  async processPayment(orderId: string, paymentId: string, amount: number): Promise<Order> {
    // 1. Ambil order dari database
    // Misalnya: const order = await this.orderRepository.findById(orderId);
    const order = new Order(orderId, "user123", 100, [{ productId: "prod1", quantity: 10 }]); // Contoh dummy

    // 2. Lakukan perubahan pada order, yang mungkin memancarkan PaymentSucceededEvent
    order.markAsPaid(paymentId, amount);

    // 3. Simpan perubahan order ke database
    // Misalnya: await this.orderRepository.update(order);
    console.log(`[Repository] Order ID: ${order.getId()} diupdate ke database. Status: ${order.getStatus()}`);

    // 4. Setelah transaksi database berhasil, ambil dan pancarkan event
    const domainEvents = order.getAndClearDomainEvents();
    await DomainEvent