Event-Driven Architecture #

There is a moment that often becomes a turning point in system design: when you realize that adding a new feature always requires changing existing code. A new notification service? Modify the Order Service. New analytics? Modify the Payment Service. A loyalty points feature? Modify every relevant service. Every feature addition spreads like water seeping through cracks — changing code that should never need touching. Event-Driven Architecture (EDA) flips this pattern: when something happens in the system, an event is published. Anyone who cares can subscribe without the publisher knowing. The Order Service does not need to know the Notification Service exists. The Payment Service does not need to know about an Analytics Service. Each service just does its job, publishes what happened, and lets the ecosystem react as each consumer needs.

Three Communication Patterns in EDA #

EDA is not a single approach — there are three distinct patterns based on how much data the event carries:

flowchart TD
    subgraph EN["Event Notification"]
        P1["Order Service"] -->|"OrderPlaced\\n{order_id: 'abc'}"| B1["Broker"]
        B1 --> C1["Notification Service\\n\\n→ fetch details 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→ process directly\\nwithout API calls"]
    end

    subgraph ES["Event Sourcing"]
        P3["Order Service"] -->|"all events\\nas the source of truth"| B3["Event Store"]
        B3 --> C3["Read Model\\n\\n→ built from\\nevent replay"]
    end
PatternEvent carriesAPI calls neededBest for
Event NotificationOnly an ID or signalYes — consumer fetches detailsFrequent events, small payloads
Event Carried State TransferComplete dataNo — self-containedConsumers needing data immediately, without API dependency
Event SourcingAll state changesNo — state is built from eventsAudit trails, time travel, CQRS

Key EDA Components #

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

Three immediate advantages of this topology:

Extensibility — adding a new LoyaltyService does not require changing OrderService or PaymentService; just subscribe to the relevant topics.

Resilience — if NotificationService is down, OrderService is unaffected; events remain stored in the broker and will be processed when NotificationService comes back online.

Scalability — each consumer can be scaled independently based on its own load.


Designing Good Events #

An event is a contract between producer and consumer. Poor design can make system evolution difficult.

// ✓ CORRECT: An event with a clear and stable structure
// Past-tense name, immutable, carries enough data

package events

import "time"

// OrderPlaced is published when an order is successfully created
// Version 1 — all fields backward compatible
type OrderPlaced struct {
	// Event metadata — required in all events
	EventID    string    `json:"event_id"`    // unique event ID (for idempotency)
	EventName  string    `json:"event_name"`  // "order.placed"
	Version    int       `json:"version"`     // 1
	OccurredAt time.Time `json:"occurred_at"`

	// Event payload
	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 is published when a payment succeeds
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: an event with an imperative name and minimal data
type CreateOrder struct { // ✗ a command name, not an event
	ID string // ✗ only an ID — consumers must make API calls for details
}

Producer Implementation: Publishing to Kafka #

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

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

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

// Producer is a type-safe Kafka writer wrapper
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, // ✓ wait for all replicas
		},
	}
}

// Publish publishes an event to Kafka
func (p *Producer) Publish(ctx context.Context, key string, event interface{}) error {
	payload, err := json.Marshal(event)
	if err != nil {
		return fmt.Errorf("failed to 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("failed to publish to kafka: %w", err)
	}
	return nil
}

func (p *Producer) Close() error {
	return p.writer.Close()
}
// internal/order/service.go — the Order Service publishes events
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
	}

	// Publish the event AFTER it is successfully saved
	// ✓ Event Carried State Transfer — carries all the data consumers need
	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 to ensure events for the same order go to the same partition
	// ✓ Preserves per-order event ordering
	if err := s.publisher.Publish(ctx, order.ID, event); err != nil {
		// Log the error but do not fail the order — use the outbox pattern for reliability
		slog.ErrorContext(ctx, "failed to publish OrderPlaced event",
			"order_id", order.ID,
			"error", err.Error(),
		)
	}

	return order, nil
}

Consumer Implementation with Idempotency #

A good consumer must be idempotent — processing the same event more than once must not produce a different effect. Kafka’s at-least-once delivery ensures an event can be delivered more than once.

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

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

	"github.com/segmentio/kafka-go"

	"myapp/events"
)

// ProcessedEventRepository stores events that have already been processed
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 for load balancing
			MinBytes:       10e3,           // 10KB
			MaxBytes:       10e6,           // 10MB
			CommitInterval: time.Second,    // auto-commit every 1 second
		}),
		notifier:     notifier,
		processedRepo: processedRepo,
	}
}

// Start begins the 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 — normal shutdown
			}
			slog.Error("failed to fetch message", "error", err)
			continue
		}

		if err := c.processMessage(ctx, msg); err != nil {
			slog.Error("failed to process message",
				"offset", msg.Offset,
				"error", err,
			)
			// Do not commit the offset — the message will be reprocessed
			continue
		}

		// Commit only after successful processing
		if err := c.reader.CommitMessages(ctx, msg); err != nil {
			slog.Error("failed to 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 {
		// Invalid event — skip (no point retrying)
		slog.Warn("invalid event, skipping", "error", err)
		return nil
	}

	// ✓ IDEMPOTENCY CHECK — check whether the event was already processed
	processed, err := c.processedRepo.IsProcessed(ctx, event.EventID)
	if err != nil {
		return err
	}
	if processed {
		slog.Info("event already processed, skipping", "event_id", event.EventID)
		return nil // idempotent — no side effects on the second time
	}

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

	// Mark as processed — AFTER success
	return c.processedRepo.MarkProcessed(ctx, event.EventID)
}

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

Dead Letter Queue: Handling Failed Events #

Not every event can be processed successfully — corrupted data, unavailable dependencies, or consumer bugs. A Dead Letter Queue (DLQ) is the safety net for events that fail after N retries:

// pkg/kafka/dlq_consumer.go — Consumer with 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 to the 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("failed to process event, retrying",
				"attempt", attempt,
				"max_retries", c.maxRetries,
				"error", lastErr,
			)

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

		if lastErr != nil {
			// ✓ Send to the DLQ after all retries fail
			dlqMsg := c.buildDLQMessage(msg, lastErr)
			if err := c.dlqWriter.WriteMessages(ctx, dlqMsg); err != nil {
				slog.Error("failed to send to DLQ", "error", err)
				continue // do not commit — try again later
			}
			slog.Error("event sent to 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()),
		}),
	}
}

The Outbox Pattern: Guaranteed Event Delivery #

A common EDA problem: how do you ensure an event is sent if the service crashes after the database commit but before publishing to the broker? The Outbox Pattern solves this:

// Outbox pattern: store the event in the database in the same transaction as the main operation
// A separate goroutine (relay) reads the outbox and publishes to the broker

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

	// ✓ One database transaction: save the order AND the event to the outbox
	err := s.db.WithTransaction(ctx, func(tx *sql.Tx) error {
		// Save the order
		if err := s.repo.SaveTx(ctx, tx, order); err != nil {
			return err
		}

		// Save the event to the outbox table — in the same transaction
		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 that publishes events from the outbox to 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) {
	// Fetch events that have not been published yet
	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 to Kafka
		if err := r.publisher.PublishRaw(ctx, topic, key, payload); err != nil {
			slog.Error("failed to publish outbox event", "id", id, "error", err)
			continue
		}

		// Mark as 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: Atomic — both succeed or both fail

    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: Process notification

Choreography vs Orchestration #

There are two approaches to coordinating cross-service flows in EDA:

flowchart TD
    subgraph CHOREO["Choreography — each service reacts to events"]
        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✓ Easy to add new services\\n✗ Business flow is scattered\\n✗ Hard end-to-end debugging"]
    end

    subgraph ORCH["Orchestration — one orchestrator controls the flow"]
        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["✓ Clear business flow\\n✓ Easy failure handling\\n✗ Orchestrator can be a bottleneck\\n✗ Coupling to the orchestrator"]
    end
AspectChoreographyOrchestration
CouplingVery lowModerate (to the orchestrator)
Flow visibilityScatteredCentralized and clear
Error handlingEach service handles its ownThe orchestrator handles it
ComplexityHigh with complex flowsMore manageable for long flows
Best forSimple flows, many independent consumersLong, stateful business workflows

Event Versioning and Schema Evolution #

An event is a long-term contract. Schemas must evolve with backward compatibility:

// events/order_placed.go — versioning event schemas
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 — adds new fields (backward compatible: new fields optional)
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"`
	// New fields — old consumers will ignore these
	DiscountCode string    `json:"discount_code,omitempty"`
	DeliveryAddr string    `json:"delivery_address,omitempty"`
}

// A consumer supporting multiple versions
func processOrderPlaced(ctx context.Context, payload []byte) error {
	// Decode the version first
	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 — do not panic for unknown versions
	}
}

Anti-Patterns to Avoid #

// ✗ Events sent BEFORE the database commit — can be inconsistent
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) error {
	order := buildOrder(input)

	// ✗ Publish first, then save — if Save fails, the event was already sent
	s.publisher.Publish(ctx, order.ID, OrderPlaced{OrderID: order.ID})
	return s.repo.Save(ctx, order) // ✗ may fail after the event was sent
}

// ✓ Publish AFTER the database commit succeeds (or use the outbox pattern)
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) error {
	order := buildOrder(input)
	if err := s.repo.Save(ctx, order); err != nil { // ✓ save first
		return err
	}
	s.publisher.Publish(ctx, order.ID, OrderPlaced{OrderID: order.ID}) // ✓ publish after success
	return nil
}

// ✗ A non-idempotent consumer — duplicate events cause double processing
func (c *Consumer) handleOrderPlaced(ctx context.Context, event OrderPlaced) error {
	// ✗ send the notification directly without checking for duplicates
	return c.mailer.Send(ctx, event.CustomerID, "Order Confirmed", buildBody(event))
}

// ✓ An idempotent consumer — check the event ID before processing
func (c *Consumer) handleOrderPlaced(ctx context.Context, event OrderPlaced) error {
	if ok, _ := c.repo.IsProcessed(ctx, event.EventID); ok {
		return nil // ✓ already processed, skip
	}
	if err := c.mailer.Send(ctx, event.CustomerID, "Order Confirmed", buildBody(event)); err != nil {
		return err
	}
	return c.repo.MarkProcessed(ctx, event.EventID) // ✓ mark after success
}

// ✗ Business logic in the broker or event schema
// An event used as a command (imperative) dictating what consumers must do
type ProcessPaymentCommand struct { // ✗ this is a command, not an event
	OrderID string
	Action  string // "process_payment" — logic in the event
}

// ✓ An event only states what happened — consumers decide how to react
type OrderPlaced struct { // ✓ past tense, facts, no instructions
	OrderID    string
	CustomerID string
	TotalCents int64
}

Event-Driven Architecture Review Checklist #

EVENT DESIGN:
  □ Event names use the past tense (OrderPlaced, PaymentFailed)
  □ Every event has a unique EventID for idempotency
  □ Events have a Version field for schema evolution
  □ Events are immutable — not modified after being published

PRODUCER:
  □ Events are published AFTER the database operation succeeds
  □ The outbox pattern is used for guaranteed delivery
  □ Partition keys are chosen to preserve required ordering

CONSUMER:
  □ All consumers are idempotent — processing the same event twice is safe
  □ Deduplication keys are stored and checked before processing
  □ Offsets are committed only after an event is successfully processed
  □ A DLQ is configured for events that fail after N retries

RELIABILITY:
  □ Retries with exponential backoff are configured
  □ The Dead Letter Queue is monitored and there is a replay process
  □ Alerts are configured when the DLQ exceeds a threshold

OBSERVABILITY:
  □ Correlation IDs / trace IDs are propagated across all events
  □ Consumer lag is monitored per topic and consumer group
  □ Event processing time is measured and alerted when slow
  □ DLQ size is monitored

SCHEMA EVOLUTION:
  □ Event changes are always backward compatible (add optional fields, never remove)
  □ Breaking changes require a new version (v2, v3)
  □ Old consumers can handle new event versions (ignore unknown fields)

Summary #

  • EDA reverses the dependency direction — producers do not know who will consume their events; adding a new consumer never requires changing the producer.
  • There are three patterns — Event Notification (signal only), Event Carried State Transfer (complete data), and Event Sourcing (events as the source of truth); choose based on consumer needs.
  • Past-tense event names, immutable payloads — events represent facts that have happened, not instructions about what to do; this is the fundamental difference between events and commands.
  • Publish AFTER the database commit, not before — an event sent before the data is stored causes inconsistency; use the outbox pattern for guaranteed delivery.
  • All consumers must be idempotent — Kafka’s at-least-once delivery ensures events can arrive more than once; a non-idempotent consumer causes data duplication.
  • A DLQ is a mandatory safety net — continuously failing events must be routed to a DLQ so they do not block other events; the DLQ must be monitored with a replay process.
  • The outbox pattern for reliability — store the event in the database in the same transaction as the main operation; a separate relay publishes to the broker.
  • Idempotency keys must be stored — use the EventID as the key; before processing, check whether the event was already handled; mark it after success.
  • Event versioning for evolution — add new fields with omitempty; never remove fields; old consumers must be able to ignore unknown fields.
  • Observability is even more mandatory than in synchronous systems — distributed tracing across events, consumer lag monitoring, and DLQ alerting must exist before EDA goes to production.

← Previous: Service-Based   Next: CQRS →

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