Event-Driven Architecture Patterns

EVENTBROKERPLM Service(Producer)Publishes:PartReleasedMES Service(Producer)Publishes:WorkOrderCompleteMES Service(Consumer)Subscribes:PartReleasedAnalytics(Consumer)Subscribes:PartReleasedInventory(Consumer)Subscribes:WorkOrderCompleteEVENT-DRIVEN ARCHITECTURE: PUB/SUBProducers publish events. Consumers subscribe. Broker decouples them.Producer (publishes events)Consumer (subscribes to events)Broker (routes events)

Core Concepts

Event-Driven Architecture (EDA) is built on three primitives: events, producers, and consumers.

Event: An immutable fact that something happened. Past-tense. PartReleased, OrderPlaced, PaymentProcessed. Not “ReleasePartCommand” (that’s an intent, not a fact).

Producer: The component that publishes events. It doesn’t know who consumes them. It just says “this happened” and moves on.

Consumer: The component that subscribes to events. It reacts — updating state, triggering workflows, sending notifications. Consumers are independent. Adding a new consumer doesn’t change existing ones.

Broker: Middleware that routes events from producers to consumers. SNS, Kafka, RabbitMQ, EventBridge. The broker decouples producers from consumers. Producers publish once; multiple consumers receive the event.

The key property: decoupling. Producers don’t call consumers directly. Consumers don’t poll producers. The broker handles routing, retries, and delivery guarantees.

Three Topologies

Event-Driven Architecture isn’t a single pattern. It’s a family of topologies, each with different trade-offs.

1. Event Notification (Thin Events)

The event contains minimal data — just an ID or a signal that something happened. Consumers who need details make a callback to fetch them.

Example:

{
  "type": "PartReleased",
  "partId": "part-12345",
  "timestamp": "2026-03-26T10:00:00Z"
}

The consumer receives this, then calls GET /api/parts/part-12345 to fetch full details.

Pros:

  • Small event payload (cheap to transmit)
  • Consumers always get fresh data (no stale cache)

Cons:

  • Consumer depends on producer’s API (not fully decoupled)
  • If producer is down, consumer can’t fetch details
  • Adds latency (extra network call)

Use case: When the producer’s state changes frequently, and consumers need the latest data.

2. Event-Carried State Transfer (Fat Events)

The event contains all relevant data. Consumers don’t need to call back.

Example:

{
  "type": "PartReleased",
  "partId": "part-12345",
  "partNumber": "PN-98765",
  "revision": "C",
  "bomLines": [
    { "childPartId": "part-11111", "quantity": 2 },
    { "childPartId": "part-22222", "quantity": 5 }
  ],
  "timestamp": "2026-03-26T10:00:00Z"
}

The consumer has everything it needs. No callback required.

Pros:

  • Full decoupling (consumer doesn’t depend on producer’s API)
  • Works even if producer is down (event has all data)
  • Faster (no extra network call)

Cons:

  • Large event payload (expensive to transmit)
  • Consumers may cache stale data (if producer updates after event)

Use case: When consumers need to operate independently, even if the producer is offline.

3. Event Sourcing (State = Replay of Events)

State is not stored directly. Instead, you store the sequence of events that produced the state. To get current state, you replay the events.

Example:

[
  { "type": "PartCreated", "partId": "part-12345", "partNumber": "PN-98765", "timestamp": "2026-03-20T08:00:00Z" },
  { "type": "PartApproved", "partId": "part-12345", "timestamp": "2026-03-25T14:30:00Z" },
  { "type": "PartReleased", "partId": "part-12345", "timestamp": "2026-03-26T10:00:00Z" }
]

Current state = apply PartCreated, then PartApproved, then PartReleased. The result: a Part in Released status.

Pros:

  • Full audit trail (you know exactly how state evolved)
  • Time travel (replay up to any point to see historical state)
  • Debugging (reproduce bugs by replaying event sequence)

Cons:

  • Complexity (replay logic, snapshotting for performance)
  • Schema evolution (old events must remain compatible)
  • Eventually consistent (rebuilding state takes time)

Use case: When audit, compliance, or debugging requirements justify the complexity. Financial systems, medical records, legal workflows.

Key Patterns

PatternDescriptionWhen to Use
Pub/SubProducer publishes to topic. Multiple consumers subscribe. Each consumer gets a copy.Fan-out: one event, multiple reactions. Example: OrderPlaced → update inventory, send email, log analytics.
Point-to-PointProducer sends to queue. One consumer dequeues. Only one consumer processes the event.Work distribution: balance load across workers. Example: image processing queue with multiple workers.
Fan-outOne event triggers multiple downstream systems. Typically implemented via Pub/Sub.Integration: notify all dependent systems of a change. Example: PartReleased → update MES, Inventory, Analytics.
Dead Letter QueueFailed events are moved to a separate queue for manual inspection.Error handling: prevent poison messages from blocking the queue. Investigate and retry failures manually.
Idempotent ConsumerConsumer handles duplicate events safely. Processing the same event twice produces the same result.At-least-once delivery: events may be delivered multiple times. Ensure repeated processing is safe.
Outbox PatternWrite event to outbox table in same transaction as state change. Separate process publishes from outbox.Transactional guarantee: ensure event is published if and only if state change succeeds.

Delivery Guarantees

When you publish an event, how many times will consumers receive it? The answer depends on the broker’s delivery guarantee.

At-Most-Once

The broker tries to deliver the event once. If delivery fails, the event is lost.

Pros: Fast. No retries, no overhead.

Cons: Events can be lost. Unacceptable for critical workflows.

Use case: Metrics, logs, low-priority telemetry. Losing a few data points is tolerable.

At-Least-Once

The broker retries delivery until the consumer acknowledges receipt. The event may be delivered multiple times.

Pros: No data loss. Events always reach consumers (eventually).

Cons: Consumers must handle duplicates. Same event may arrive 2x, 3x, or more.

Use case: Most production systems. Pair with Idempotent Consumer pattern to handle duplicates.

Exactly-Once (The Lie)

The broker guarantees the event is delivered exactly once. No loss, no duplicates.

Reality: True exactly-once is impossible in distributed systems. What “exactly-once” brokers actually provide is idempotent delivery — they ensure duplicate delivery is invisible to consumers, usually by tracking delivery state in the broker.

Pros: Simplest consumer logic (no duplicate handling needed).

Cons: Expensive. Requires coordination, state tracking, and performance trade-offs.

Use case: Financial transactions, billing, inventory adjustments. When correctness is more important than latency.

Ordering Challenges

Events are published in order. But consumers may receive them out of order. This happens due to network delays, retries, and parallel processing.

Example:

  1. PartApproved published at 10:00
  2. PartReleased published at 10:01
  3. Consumer receives PartReleased first (network delay on PartApproved)
  4. Consumer processes PartReleased → error (Part not yet Approved)

Solution 1: Partition Keys

Assign events to partitions based on a key (e.g., partId). Events in the same partition are delivered in order.

Kafka, Kinesis, and EventBridge support partition keys. Events with the same key go to the same partition, preserving order.

Trade-off: Events in different partitions may still be out of order. This is acceptable if ordering only matters within an entity (e.g., all events for part-12345 are ordered, but events for part-12345 and part-67890 are independent).

Solution 2: Sequence Numbers

Embed a sequence number in each event. Consumer checks the sequence and buffers out-of-order events.

Example:

{ "type": "PartApproved", "partId": "part-12345", "seq": 1 }
{ "type": "PartReleased", "partId": "part-12345", "seq": 2 }

If consumer receives seq: 2 before seq: 1, it buffers until seq: 1 arrives.

Trade-off: Adds complexity. Consumers need buffering logic and timeout handling (what if seq: 1 never arrives?).

Events vs Commands vs Queries

Not every message is an event. There are three types of messages in distributed systems:

TypeDescriptionExample
EventPast-tense fact. Something happened.PartReleased, OrderShipped
CommandImperative request. Do this.ReleasePart, ShipOrder
QueryRequest for data. What is the state?GetPartDetails, CheckInventory

When to use events:

  • Notification: “I did this, in case you care.”
  • Integration: Multiple systems react to the same event.
  • Audit: Need a record of what happened.

When to use commands:

  • Directed action: “You should do this.”
  • Single recipient: One system is responsible.
  • Synchronous: Caller waits for result.

When to use queries:

  • Read-only: No state change.
  • Synchronous response: Caller needs the data immediately.

Key difference: Events are published (fire-and-forget). Commands and queries are sent to a specific recipient (request-response).

The Outbox Pattern in Detail

The Outbox Pattern solves a critical problem: how do you atomically update state and publish an event?

If you update the database, then publish an event, the publish might fail. The database is updated, but downstream systems never know.

If you publish an event, then update the database, the update might fail. Downstream systems react to an event that never actually happened.

The Solution

Write the event to an “outbox” table in the same transaction as the state change.

BEGIN TRANSACTION;
  UPDATE parts SET status = 'Released' WHERE id = 'part-12345';
  INSERT INTO outbox (event_type, payload) VALUES ('PartReleased', '{"partId": "part-12345", ...}');
COMMIT;

Both the state update and the outbox entry succeed or fail together. Transactional guarantee.

A separate background process polls the outbox table and publishes events to the broker. Once published, the outbox entry is deleted (or marked as published).

Why It Works

  • State change and event are in the same transaction → atomic.
  • If the transaction fails, neither happens.
  • If the transaction succeeds, the event is in the outbox.
  • Background publisher is idempotent → retries are safe.
  • If publish fails, the publisher retries until success.

This is the standard pattern for transactional event publishing. Most production systems use some variant of the Outbox Pattern.

Real Example: PLM System Events

Let’s see how events work in a manufacturing PLM system.

Event: PartReleased

Producer: PLM Service

When a Part is released, PLM publishes:

{
  "eventId": "evt-98765",
  "type": "PartReleased",
  "partId": "part-12345",
  "partNumber": "PN-98765",
  "revision": "C",
  "bom": [
    { "childPartNumber": "PN-11111", "quantity": 2 },
    { "childPartNumber": "PN-22222", "quantity": 5 }
  ],
  "releasedBy": "user-456",
  "timestamp": "2026-03-26T10:00:00Z"
}

Consumers:

  1. MES (Manufacturing Execution System): Subscribes to PartReleased. Creates production record with routing and work centers.

  2. Inventory: Subscribes to PartReleased. Initializes stock record for the new part.

  3. Analytics: Subscribes to all events. Logs PartReleased for reporting and dashboards.

Event: BomUpdated

Producer: PLM Service

When a BOM changes (part added, quantity changed):

{
  "eventId": "evt-98766",
  "type": "BomUpdated",
  "partId": "part-12345",
  "changes": [
    { "action": "add", "childPartNumber": "PN-33333", "quantity": 3 },
    { "action": "update", "childPartNumber": "PN-11111", "oldQuantity": 2, "newQuantity": 4 }
  ],
  "timestamp": "2026-03-27T14:30:00Z"
}

Consumers:

  1. MES: Subscribes to BomUpdated. Adjusts production plans to reflect new BOM.

  2. Inventory: Subscribes to BomUpdated. Recalculates material requirements.

Event: EcoApproved

Producer: PLM Service

When an Engineering Change Order is approved:

{
  "eventId": "evt-98767",
  "type": "EcoApproved",
  "ecoId": "eco-555",
  "affectedParts": ["part-12345", "part-67890"],
  "approvedBy": "user-789",
  "timestamp": "2026-03-28T09:00:00Z"
}

Consumers:

  1. MES: Subscribes to EcoApproved. Flags affected work orders for review.

  2. Notifications: Subscribes to EcoApproved. Sends email to stakeholders.

Conclusion

Event-Driven Architecture is not a single pattern. It’s a family of topologies — Event Notification, Event-Carried State Transfer, Event Sourcing — each with different trade-offs.

Events are facts. Past-tense, immutable. They represent what happened, not what should happen.

Producers and consumers are decoupled. Producers don’t know who consumes events. Consumers don’t know who produces them. The broker routes.

Delivery guarantees matter. At-most-once (fast, lossy), at-least-once (reliable, duplicates), exactly-once (expensive, complex). Choose based on your tolerance for data loss vs duplicate handling.

Ordering is hard. Use partition keys or sequence numbers to preserve order when needed.

Events vs commands vs queries. Events notify. Commands direct. Queries request. Use the right message type for the job.

The Outbox Pattern ensures atomicity. Write event to database in same transaction as state change. Publish asynchronously. No lost events.

Event-Driven Architecture enables loose coupling, scalability, and system independence. But it adds complexity: eventual consistency, ordering challenges, and failure modes. Use it when the benefits (decoupling, fan-out, integration) justify the cost.

Good event design is past-tense, immutable, and self-contained. Bad event design is imperative, mutable, or requires callbacks.

That’s event-driven architecture.