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| Pattern | Event carries | API calls needed | Best for |
|---|---|---|---|
| Event Notification | Only an ID or signal | Yes — consumer fetches details | Frequent events, small payloads |
| Event Carried State Transfer | Complete data | No — self-contained | Consumers needing data immediately, without API dependency |
| Event Sourcing | All state changes | No — state is built from events | Audit 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 & ASThree 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 notificationChoreography 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| Aspect | Choreography | Orchestration |
|---|---|---|
| Coupling | Very low | Moderate (to the orchestrator) |
| Flow visibility | Scattered | Centralized and clear |
| Error handling | Each service handles its own | The orchestrator handles it |
| Complexity | High with complex flows | More manageable for long flows |
| Best for | Simple flows, many independent consumers | Long, 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.