Event-Driven Architecture #

Ada satu momen yang sering menjadi titik balik dalam desain sistem: ketika kamu menyadari bahwa menambahkan fitur baru selalu membutuhkan mengubah kode yang sudah ada. Layanan notifikasi baru? Ubah Order Service. Analytics baru? Ubah Payment Service. Fitur loyalty points? Ubah semua service yang relevan. Setiap penambahan fitur menyebar seperti air yang mengalir ke celah-celah — mengubah kode yang seharusnya tidak perlu disentuh. Event-Driven Architecture (EDA) membalik pola ini: ketika sesuatu terjadi di sistem, sebuah event dipublikasikan. Siapapun yang peduli bisa subscribe tanpa sepengetahuan publisher. Order Service tidak perlu tahu ada Notification Service. Payment Service tidak perlu tahu ada Analytics Service. Setiap service hanya mengerjakan tugasnya, mempublikasikan apa yang terjadi, dan membiarkan ekosistem bereaksi sesuai kebutuhannya masing-masing.

Tiga Pola Komunikasi dalam EDA #

EDA bukan satu cara tunggal — ada tiga pola yang berbeda berdasarkan seberapa banyak data yang dibawa event:

flowchart TD
    subgraph EN["Event Notification"]
        P1["Order Service"] -->|"OrderPlaced\n{order_id: 'abc'}"| B1["Broker"]
        B1 --> C1["Notification Service\n\n→ fetch detail via API"]
    end

    subgraph ECST["Event Carried State Transfer"]
        P2["Order Service"] -->|"OrderPlaced\n{order_id, customer_id,\nitems, total, address}"| B2["Broker"]
        B2 --> C2["Notification Service\n\n→ langsung proses\ntanpa API call"]
    end

    subgraph ES["Event Sourcing"]
        P3["Order Service"] -->|"semua event\nsebagai sumber kebenaran"| B3["Event Store"]
        B3 --> C3["Read Model\n\n→ dibangun dari\nreplay event"]
    end
Pola Event membawa Kebutuhan API call Cocok untuk
Event Notification Hanya ID atau sinyal Ya — consumer fetch detail Event yang frequent, payload kecil
Event Carried State Transfer Data lengkap Tidak — self-contained Consumer butuh data segera, tanpa API dependency
Event Sourcing Semua perubahan state Tidak — state dibangun dari event Audit trail, time travel, CQRS

Komponen Utama EDA #

flowchart LR
    subgraph PROD["Producers"]
        OS["Order Service"]
        PS["Payment Service"]
        US["User Service"]
    end

    subgraph BROKER["Event Broker"]
        T1["topic: order.events"]
        T2["topic: payment.events"]
        T3["topic: user.events"]
    end

    subgraph CONS["Consumers"]
        NS["Notification Service"]
        AS["Analytics Service"]
        IS["Inventory Service"]
        LS["Loyalty Service"]
    end

    OS -->|"OrderPlaced, OrderCancelled"| T1
    PS -->|"PaymentProcessed, PaymentFailed"| T2
    US -->|"UserRegistered, UserUpdated"| T3

    T1 --> NS & AS & IS & LS
    T2 --> NS & AS & LS
    T3 --> NS & AS

Tiga keuntungan langsung dari topologi ini:

Extensibility — menambahkan LoyaltyService baru tidak perlu mengubah OrderService atau PaymentService; cukup subscribe ke topic yang relevan.

Resilience — jika NotificationService down, OrderService tidak terpengaruh; event tetap tersimpan di broker dan akan diproses saat NotificationService kembali online.

Scalability — setiap consumer bisa di-scale secara independen berdasarkan beban masing-masing.


Desain Event yang Baik #

Event adalah kontrak antara producer dan consumer. Desain yang buruk bisa mempersulit evolusi sistem.

// ✓ BENAR: Event dengan struktur yang jelas dan stabil
// Nama past tense, immutable, membawa data yang cukup

package events

import "time"

// OrderPlaced dipublikasikan ketika order berhasil dibuat
// Versi 1 — semua field backward compatible
type OrderPlaced struct {
	// Metadata event — wajib ada di semua event
	EventID    string    `json:"event_id"`    // ID unik event (untuk idempotency)
	EventName  string    `json:"event_name"`  // "order.placed"
	Version    int       `json:"version"`     // 1
	OccurredAt time.Time `json:"occurred_at"`

	// Payload event
	OrderID    string      `json:"order_id"`
	CustomerID string      `json:"customer_id"`
	Items      []OrderItem `json:"items"`
	TotalCents int64       `json:"total_cents"`
	Currency   string      `json:"currency"`
}

type OrderItem struct {
	ProductID  string `json:"product_id"`
	Name       string `json:"name"`
	Quantity   int    `json:"quantity"`
	PriceCents int64  `json:"price_cents"`
}

// PaymentProcessed dipublikasikan ketika payment berhasil
type PaymentProcessed struct {
	EventID       string    `json:"event_id"`
	EventName     string    `json:"event_name"` // "payment.processed"
	Version       int       `json:"version"`
	OccurredAt    time.Time `json:"occurred_at"`

	PaymentID  string `json:"payment_id"`
	OrderID    string `json:"order_id"`
	AmountCents int64 `json:"amount_cents"`
	Method     string `json:"method"` // "credit_card", "bank_transfer"
}

// ✗ ANTI-PATTERN: event dengan nama imperatif dan data minimal
type CreateOrder struct { // ✗ nama command, bukan event
	ID string // ✗ hanya ID — consumer harus API call untuk mendapat detail
}

Implementasi Producer: Publish ke Kafka #

// pkg/kafka/producer.go — generic Kafka producer
package kafka

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/segmentio/kafka-go"
)

// Producer adalah wrapper Kafka writer yang type-safe
type Producer struct {
	writer *kafka.Writer
}

func NewProducer(brokers []string, topic string) *Producer {
	return &Producer{
		writer: &kafka.Writer{
			Addr:         kafka.TCP(brokers...),
			Topic:        topic,
			Balancer:     &kafka.LeastBytes{},
			WriteTimeout: 5 * time.Second,
			RequiredAcks: kafka.RequireAll, // ✓ tunggu semua replica
		},
	}
}

// Publish mempublikasikan event ke Kafka
func (p *Producer) Publish(ctx context.Context, key string, event interface{}) error {
	payload, err := json.Marshal(event)
	if err != nil {
		return fmt.Errorf("gagal marshal event: %w", err)
	}

	err = p.writer.WriteMessages(ctx, kafka.Message{
		Key:   []byte(key),
		Value: payload,
		Headers: []kafka.Header{
			{Key: "content-type", Value: []byte("application/json")},
		},
	})
	if err != nil {
		return fmt.Errorf("gagal publish ke kafka: %w", err)
	}
	return nil
}

func (p *Producer) Close() error {
	return p.writer.Close()
}
// internal/order/service.go — Order Service mempublikasikan event
package order

import (
	"context"
	"time"

	"myapp/events"
	"myapp/pkg/kafka"
)

type EventPublisher interface {
	Publish(ctx context.Context, key string, event interface{}) error
}

type OrderService struct {
	repo      OrderRepository
	publisher EventPublisher
}

func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Order, error) {
	order := buildOrder(input)

	if err := s.repo.Save(ctx, order); err != nil {
		return nil, err
	}

	// Publikasikan event SETELAH berhasil disimpan
	// ✓ Event Carried State Transfer — membawa semua data yang dibutuhkan consumer
	event := events.OrderPlaced{
		EventID:    generateEventID(),
		EventName:  "order.placed",
		Version:    1,
		OccurredAt: time.Now(),
		OrderID:    order.ID,
		CustomerID: order.CustomerID,
		Items:      toEventItems(order.Items),
		TotalCents: order.TotalCents,
		Currency:   "IDR",
	}

	// Key = OrderID untuk memastikan event order yang sama masuk ke partisi yang sama
	// ✓ Menjaga ordering event per order
	if err := s.publisher.Publish(ctx, order.ID, event); err != nil {
		// Log error tapi jangan gagalkan order — gunakan outbox pattern untuk reliability
		slog.ErrorContext(ctx, "gagal publish OrderPlaced event",
			"order_id", order.ID,
			"error", err.Error(),
		)
	}

	return order, nil
}

Implementasi Consumer dengan Idempotency #

Consumer yang baik harus idempotent — memproses event yang sama lebih dari sekali tidak boleh menghasilkan efek yang berbeda. Kafka at-least-once delivery memastikan event bisa terkirim lebih dari sekali.

// internal/notification/consumer.go — Notification Service consumer
package notification

import (
	"context"
	"encoding/json"
	"log/slog"
	"time"

	"github.com/segmentio/kafka-go"

	"myapp/events"
)

// ProcessedEventRepository untuk menyimpan event yang sudah diproses
type ProcessedEventRepository interface {
	IsProcessed(ctx context.Context, eventID string) (bool, error)
	MarkProcessed(ctx context.Context, eventID string) error
}

type Consumer struct {
	reader       *kafka.Reader
	notifier     Notifier
	processedRepo ProcessedEventRepository
}

func NewConsumer(brokers []string, topic, groupID string, notifier Notifier, processedRepo ProcessedEventRepository) *Consumer {
	return &Consumer{
		reader: kafka.NewReader(kafka.ReaderConfig{
			Brokers:        brokers,
			Topic:          topic,
			GroupID:        groupID,        // ✓ consumer group untuk load balancing
			MinBytes:       10e3,           // 10KB
			MaxBytes:       10e6,           // 10MB
			CommitInterval: time.Second,    // auto commit setiap 1 detik
		}),
		notifier:     notifier,
		processedRepo: processedRepo,
	}
}

// Start memulai consumer loop
func (c *Consumer) Start(ctx context.Context) error {
	for {
		msg, err := c.reader.FetchMessage(ctx)
		if err != nil {
			if ctx.Err() != nil {
				return nil // context cancelled — shutdown normal
			}
			slog.Error("gagal fetch message", "error", err)
			continue
		}

		if err := c.processMessage(ctx, msg); err != nil {
			slog.Error("gagal proses message",
				"offset", msg.Offset,
				"error", err,
			)
			// Tidak commit offset — message akan diproses ulang
			continue
		}

		// Commit hanya setelah berhasil diproses
		if err := c.reader.CommitMessages(ctx, msg); err != nil {
			slog.Error("gagal commit offset", "error", err)
		}
	}
}

func (c *Consumer) processMessage(ctx context.Context, msg kafka.Message) error {
	var event events.OrderPlaced
	if err := json.Unmarshal(msg.Value, &event); err != nil {
		// Event tidak valid — skip (tidak ada gunanya retry)
		slog.Warn("event tidak valid, skip", "error", err)
		return nil
	}

	// ✓ IDEMPOTENCY CHECK — cek apakah event sudah pernah diproses
	processed, err := c.processedRepo.IsProcessed(ctx, event.EventID)
	if err != nil {
		return err
	}
	if processed {
		slog.Info("event sudah diproses, skip", "event_id", event.EventID)
		return nil // idempotent — tidak ada efek samping kedua kali
	}

	// Proses event
	if err := c.notifier.SendOrderConfirmation(ctx, event); err != nil {
		return err
	}

	// Tandai sudah diproses — SETELAH berhasil
	return c.processedRepo.MarkProcessed(ctx, event.EventID)
}

func (c *Consumer) Close() error {
	return c.reader.Close()
}

Dead Letter Queue: Menangani Event yang Gagal #

Tidak semua event bisa diproses berhasil — data korup, dependency tidak tersedia, atau bug di consumer. Dead Letter Queue (DLQ) adalah safety net untuk event yang gagal setelah N retry:

// pkg/kafka/dlq_consumer.go — Consumer dengan DLQ support
package kafka

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"time"

	kgo "github.com/segmentio/kafka-go"
)

type ProcessFunc func(ctx context.Context, msg kgo.Message) error

type DLQConsumer struct {
	reader      *kgo.Reader
	dlqWriter   *kgo.Writer // writer ke DLQ topic
	process     ProcessFunc
	maxRetries  int
}

func NewDLQConsumer(
	brokers []string,
	topic, groupID, dlqTopic string,
	process ProcessFunc,
	maxRetries int,
) *DLQConsumer {
	return &DLQConsumer{
		reader: kgo.NewReader(kgo.ReaderConfig{
			Brokers: brokers,
			Topic:   topic,
			GroupID: groupID,
		}),
		dlqWriter: &kgo.Writer{
			Addr:  kgo.TCP(brokers...),
			Topic: dlqTopic,
		},
		process:    process,
		maxRetries: maxRetries,
	}
}

func (c *DLQConsumer) Start(ctx context.Context) error {
	for {
		msg, err := c.reader.FetchMessage(ctx)
		if err != nil {
			if ctx.Err() != nil {
				return nil
			}
			continue
		}

		var lastErr error
		for attempt := 1; attempt <= c.maxRetries; attempt++ {
			lastErr = c.process(ctx, msg)
			if lastErr == nil {
				break
			}

			slog.Warn("gagal proses event, retry",
				"attempt", attempt,
				"max_retries", c.maxRetries,
				"error", lastErr,
			)

			// Exponential backoff sebelum retry
			if attempt < c.maxRetries {
				select {
				case <-ctx.Done():
					return nil
				case <-time.After(time.Duration(attempt*attempt) * 100 * time.Millisecond):
				}
			}
		}

		if lastErr != nil {
			// ✓ Kirim ke DLQ setelah semua retry gagal
			dlqMsg := c.buildDLQMessage(msg, lastErr)
			if err := c.dlqWriter.WriteMessages(ctx, dlqMsg); err != nil {
				slog.Error("gagal kirim ke DLQ", "error", err)
				continue // jangan commit — coba lagi nanti
			}
			slog.Error("event dikirim ke DLQ",
				"topic", msg.Topic,
				"offset", msg.Offset,
				"error", lastErr,
			)
		}

		c.reader.CommitMessages(ctx, msg)
	}
}

func (c *DLQConsumer) buildDLQMessage(original kgo.Message, err error) kgo.Message {
	type DLQEnvelope struct {
		OriginalTopic   string    `json:"original_topic"`
		OriginalOffset  int64     `json:"original_offset"`
		OriginalPayload string    `json:"original_payload"`
		ErrorMessage    string    `json:"error_message"`
		FailedAt        time.Time `json:"failed_at"`
	}

	envelope, _ := json.Marshal(DLQEnvelope{
		OriginalTopic:   original.Topic,
		OriginalOffset:  original.Offset,
		OriginalPayload: string(original.Value),
		ErrorMessage:    err.Error(),
		FailedAt:        time.Now(),
	})

	return kgo.Message{
		Key:   original.Key,
		Value: envelope,
		Headers: append(original.Headers, kgo.Header{
			Key:   "x-dlq-reason",
			Value: []byte(err.Error()),
		}),
	}
}

Outbox Pattern: Garanteed Event Delivery #

Masalah umum di EDA: bagaimana memastikan event terkirim jika service crash setelah commit database tapi sebelum publish ke broker? Outbox Pattern menyelesaikan ini:

// Outbox pattern: simpan event ke database dalam satu transaksi dengan operasi utama
// Goroutine terpisah (relay) membaca outbox dan publish ke broker

// internal/order/service.go — dengan outbox pattern
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Order, error) {
	order := buildOrder(input)
	event := buildOrderPlacedEvent(order)

	// ✓ Satu transaksi database: simpan order DAN event ke outbox
	err := s.db.WithTransaction(ctx, func(tx *sql.Tx) error {
		// Simpan order
		if err := s.repo.SaveTx(ctx, tx, order); err != nil {
			return err
		}

		// Simpan event ke outbox table — dalam transaksi yang sama
		eventPayload, _ := json.Marshal(event)
		_, err := tx.ExecContext(ctx,
			`INSERT INTO outbox_events (id, topic, key, payload, created_at, published)
			 VALUES ($1, $2, $3, $4, NOW(), FALSE)`,
			event.EventID, "order.events", order.ID, eventPayload,
		)
		return err
	})
	if err != nil {
		return nil, err
	}

	return order, nil
}

// internal/outbox/relay.go — goroutine yang publish event dari outbox ke Kafka
type OutboxRelay struct {
	db        *sql.DB
	publisher EventPublisher
	interval  time.Duration
}

func (r *OutboxRelay) Start(ctx context.Context) {
	ticker := time.NewTicker(r.interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			r.publishPendingEvents(ctx)
		}
	}
}

func (r *OutboxRelay) publishPendingEvents(ctx context.Context) {
	// Ambil event yang belum dipublikasikan
	rows, err := r.db.QueryContext(ctx,
		`SELECT id, topic, key, payload FROM outbox_events
		 WHERE published = FALSE
		 ORDER BY created_at
		 LIMIT 100`,
	)
	if err != nil {
		return
	}
	defer rows.Close()

	for rows.Next() {
		var id, topic, key string
		var payload []byte
		rows.Scan(&id, &topic, &key, &payload)

		// Publish ke Kafka
		if err := r.publisher.PublishRaw(ctx, topic, key, payload); err != nil {
			slog.Error("gagal publish outbox event", "id", id, "error", err)
			continue
		}

		// Tandai sebagai published
		r.db.ExecContext(ctx,
			`UPDATE outbox_events SET published = TRUE, published_at = NOW() WHERE id = $1`,
			id,
		)
	}
}
sequenceDiagram
    participant OS as Order Service
    participant DB as Database
    participant OR as Outbox Relay
    participant KB as Kafka Broker
    participant NS as Notification Service

    OS->>DB: BEGIN TRANSACTION
    OS->>DB: INSERT orders (order data)
    OS->>DB: INSERT outbox_events (event payload, published=FALSE)
    OS->>DB: COMMIT
    Note over OS,DB: Atomik — keduanya berhasil atau keduanya gagal

    OR->>DB: SELECT outbox WHERE published=FALSE
    DB-->>OR: [event1, event2]
    OR->>KB: Publish event1
    KB-->>OR: ACK
    OR->>DB: UPDATE outbox SET published=TRUE

    KB->>NS: Consume event1
    NS->>NS: Proses notifikasi

Choreography vs Orchestration #

Ada dua pendekatan untuk mengkoordinasikan alur lintas service di EDA:

flowchart TD
    subgraph CHOREO["Choreography — Setiap service bereaksi pada event"]
        OS1["Order Service"] -->|"OrderPlaced"| MB1["Broker"]
        MB1 --> PS1["Payment Service"]
        PS1 -->|"PaymentProcessed"| MB1
        MB1 --> IS1["Inventory Service"]
        IS1 -->|"InventoryReserved"| MB1
        MB1 --> NS1["Notification Service"]
        Note1["✓ Loose coupling\n✓ Mudah tambah service baru\n✗ Alur bisnis tersebar\n✗ Sulit debug end-to-end"]
    end

    subgraph ORCH["Orchestration — Satu orchestrator mengontrol alur"]
        SAGA["Order Saga\nOrchestrator"]
        SAGA -->|"ProcessPayment"| PS2["Payment Service"]
        PS2 -->|"PaymentResult"| SAGA
        SAGA -->|"ReserveInventory"| IS2["Inventory Service"]
        IS2 -->|"InventoryResult"| SAGA
        SAGA -->|"SendNotification"| NS2["Notification Service"]
        Note2["✓ Alur bisnis jelas\n✓ Mudah handle failure\n✗ Orchestrator bisa jadi bottleneck\n✗ Coupling ke orchestrator"]
    end
Aspek Choreography Orchestration
Coupling Sangat rendah Sedang (ke orchestrator)
Visibilitas alur Tersebar Terpusat dan jelas
Error handling Tiap service tanggung jawab sendiri Orchestrator yang menangani
Kompleksitas Tinggi saat flow rumit Lebih terkelola untuk flow panjang
Cocok untuk Alur sederhana, banyak consumer independen Bisnis workflow yang panjang dan stateful

Event Versioning dan Schema Evolution #

Event adalah kontrak jangka panjang. Schema harus berevolusi dengan backward compatibility:

// events/order_placed.go — versioning event schema
package events

// V1 — initial schema
type OrderPlacedV1 struct {
	EventID    string    `json:"event_id"`
	Version    int       `json:"version"`    // = 1
	OrderID    string    `json:"order_id"`
	CustomerID string    `json:"customer_id"`
	TotalCents int64     `json:"total_cents"`
	OccurredAt time.Time `json:"occurred_at"`
}

// V2 — tambah field baru (backward compatible: field baru opsional)
type OrderPlacedV2 struct {
	EventID      string    `json:"event_id"`
	Version      int       `json:"version"`    // = 2
	OrderID      string    `json:"order_id"`
	CustomerID   string    `json:"customer_id"`
	TotalCents   int64     `json:"total_cents"`
	OccurredAt   time.Time `json:"occurred_at"`
	// Field baru — consumer lama akan ignore ini
	DiscountCode string    `json:"discount_code,omitempty"`
	DeliveryAddr string    `json:"delivery_address,omitempty"`
}

// Consumer yang mendukung multiple versi
func processOrderPlaced(ctx context.Context, payload []byte) error {
	// Decode versi dulu
	var meta struct {
		Version int `json:"version"`
	}
	json.Unmarshal(payload, &meta)

	switch meta.Version {
	case 1:
		var event OrderPlacedV1
		json.Unmarshal(payload, &event)
		return handleV1(ctx, event)
	case 2:
		var event OrderPlacedV2
		json.Unmarshal(payload, &event)
		return handleV2(ctx, event)
	default:
		slog.Warn("unknown event version", "version", meta.Version)
		return nil // skip — jangan panic untuk versi yang tidak dikenal
	}
}

Anti-Pattern yang Harus Dihindari #

// ✗ Event dikirim SEBELUM database commit — bisa inkonsisten
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) error {
	order := buildOrder(input)

	// ✗ Publish dulu baru simpan — jika Save gagal, event sudah terkirim
	s.publisher.Publish(ctx, order.ID, OrderPlaced{OrderID: order.ID})
	return s.repo.Save(ctx, order) // ✗ mungkin gagal setelah event terkirim
}

// ✓ Publish SETELAH database commit berhasil (atau gunakan outbox pattern)
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) error {
	order := buildOrder(input)
	if err := s.repo.Save(ctx, order); err != nil { // ✓ simpan dulu
		return err
	}
	s.publisher.Publish(ctx, order.ID, OrderPlaced{OrderID: order.ID}) // ✓ publish setelah sukses
	return nil
}

// ✗ Consumer tidak idempotent — duplikat event menyebabkan double processing
func (c *Consumer) handleOrderPlaced(ctx context.Context, event OrderPlaced) error {
	// ✗ langsung kirim notifikasi tanpa cek duplikat
	return c.mailer.Send(ctx, event.CustomerID, "Order Confirmed", buildBody(event))
}

// ✓ Consumer idempotent — cek event ID sebelum proses
func (c *Consumer) handleOrderPlaced(ctx context.Context, event OrderPlaced) error {
	if ok, _ := c.repo.IsProcessed(ctx, event.EventID); ok {
		return nil // ✓ sudah diproses, skip
	}
	if err := c.mailer.Send(ctx, event.CustomerID, "Order Confirmed", buildBody(event)); err != nil {
		return err
	}
	return c.repo.MarkProcessed(ctx, event.EventID) // ✓ tandai setelah sukses
}

// ✗ Business logic di broker atau event schema
// Event sebagai command (imperatif) yang mendikte apa yang harus dilakukan consumer
type ProcessPaymentCommand struct { // ✗ ini command, bukan event
	OrderID string
	Action  string // "process_payment" — logic di event
}

// ✓ Event hanya menyatakan apa yang terjadi — consumer yang memutuskan reaksinya
type OrderPlaced struct { // ✓ past tense, facts, no instructions
	OrderID    string
	CustomerID string
	TotalCents int64
}

Checklist Review Event-Driven Architecture #

DESAIN EVENT:
  □ Nama event menggunakan past tense (OrderPlaced, PaymentFailed)
  □ Setiap event punya EventID unik untuk idempotency
  □ Event memiliki field Version untuk schema evolution
  □ Event immutable — tidak dimodifikasi setelah dipublikasikan

PRODUCER:
  □ Event dipublikasikan SETELAH operasi database berhasil
  □ Outbox pattern digunakan untuk guaranteed delivery
  □ Partition key dipilih untuk menjaga ordering yang diperlukan

CONSUMER:
  □ Semua consumer idempotent — memproses event yang sama dua kali aman
  □ Deduplication key disimpan dan di-check sebelum proses
  □ Commit offset hanya setelah event berhasil diproses
  □ DLQ dikonfigurasi untuk event yang gagal setelah N retry

RELIABILITY:
  □ Retry dengan exponential backoff dikonfigurasi
  □ Dead Letter Queue dimonitor dan ada proses untuk replay
  □ Alert dikonfigurasi saat DLQ melebihi threshold

OBSERVABILITY:
  □ Correlation ID / trace ID dipropagasi di semua event
  □ Consumer lag dimonitor per topic dan consumer group
  □ Event processing time diukur dan di-alert jika lambat
  □ DLQ size dimonitor

SCHEMA EVOLUTION:
  □ Perubahan event selalu backward compatible (tambah field opsional, jangan hapus)
  □ Breaking change memerlukan versi baru (v2, v3)
  □ Consumer lama bisa handle event versi baru (ignore unknown field)

Ringkasan #

  • EDA membalik arah dependency — producer tidak tahu siapa yang akan mengonsumsi event-nya; menambahkan consumer baru tidak perlu mengubah producer sama sekali.
  • Ada tiga pola — Event Notification (hanya sinyal), Event Carried State Transfer (data lengkap), dan Event Sourcing (event sebagai sumber kebenaran); pilih berdasarkan kebutuhan consumer.
  • Nama event past tense, payload immutable — event merepresentasikan fakta yang telah terjadi, bukan instruksi apa yang harus dilakukan; ini perbedaan fundamental antara event dan command.
  • Publish SETELAH commit database, bukan sebelum — event yang terkirim sebelum data tersimpan menyebabkan inkonsistensi; gunakan outbox pattern untuk guaranteed delivery.
  • Semua consumer harus idempotent — Kafka at-least-once delivery memastikan event bisa terkirim lebih dari sekali; consumer yang tidak idempotent akan menyebabkan duplikasi data.
  • DLQ adalah safety net yang wajib ada — event yang terus gagal harus diarahkan ke DLQ agar tidak memblokir event lain; DLQ harus dimonitor dan ada proses replay.
  • Outbox pattern untuk reliability — simpan event di database dalam satu transaksi dengan operasi utama; relay terpisah yang mempublikasikan ke broker.
  • Idempotency key harus disimpan — gunakan EventID sebagai key; sebelum proses, cek apakah event sudah pernah diproses; tandai setelah berhasil.
  • Event versioning untuk evolution — tambah field baru dengan omitempty; jangan hapus field; consumer lama harus bisa ignore field yang tidak dikenal.
  • Observability wajib lebih dari sistem sync — distributed tracing lintas event, consumer lag monitoring, dan DLQ alerting harus ada sebelum EDA masuk production.

← Sebelumnya: Service-Based Architecture   Berikutnya: CQRS Architecture →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact