Overview
Event-Driven Architecture (EDA) structures applications around the production, detection, consumption, and reaction to events. An event represents a significant change in state or an occurrence within a system. Unlike traditional request-response architectures where components interact through direct method calls, EDA components remain loosely coupled and communicate asynchronously through events.
In EDA, event producers emit events when state changes occur, without knowing which components will consume those events. Event consumers subscribe to specific event types and react when relevant events occur. This decoupling allows systems to scale independently, adapt to changing requirements, and handle complex workflows across distributed components.
The architecture gained prominence with the rise of distributed systems and microservices, where tight coupling between services creates maintenance burdens and scaling bottlenecks. EDA addresses these challenges by introducing an intermediary layer that handles event routing, delivery guarantees, and temporal decoupling.
# Traditional request-response
class OrderController
def create
order = Order.create(params)
InventoryService.reduce_stock(order.items)
PaymentService.charge_card(order.payment)
EmailService.send_confirmation(order.user)
end
end
# Event-driven approach
class OrderController
def create
order = Order.create(params)
EventBus.publish('order.created', order: order)
end
end
# Separate handlers react to the event
class InventoryHandler
def on_order_created(event)
reduce_stock(event.data[:order].items)
end
end
EDA applies to applications of varying scale, from single-process event buses to distributed message brokers coordinating hundreds of services. The pattern appears in user interface frameworks, backend microservices, IoT systems, and real-time data pipelines.
Key Principles
EDA operates on several foundational concepts that distinguish it from synchronous architectures.
Events represent immutable facts about something that happened in the system. Each event contains metadata (timestamp, source, type) and payload data describing what occurred. Events are named in past tense to reflect completed actions: OrderPlaced, PaymentProcessed, InventoryUpdated. The immutability of events creates an audit trail and enables event sourcing patterns.
Event Producers generate events when significant state changes occur within their domain. Producers remain unaware of consumers, publishing events to a channel or broker without expecting responses. This ignorance enables producers to evolve independently of consumers. A producer might emit multiple event types based on different operations within its domain.
Event Consumers subscribe to specific event types and execute logic when matching events arrive. Multiple consumers can react to the same event independently, each performing different business logic. Consumers process events asynchronously, meaning producers don't wait for consumer completion. Consumer failures don't affect producers or other consumers processing the same event.
Event Channels transport events from producers to consumers. Channels might be in-memory queues, message brokers, or event streaming platforms. The channel provides durability guarantees, ordering semantics, and delivery patterns (at-most-once, at-least-once, exactly-once). Channel selection affects system characteristics like latency, throughput, and reliability.
Temporal Decoupling separates event production time from consumption time. Producers emit events immediately when state changes occur, while consumers process events at their own pace. This decoupling allows consumers to be offline during production, process events in batches, or lag behind real-time event production without affecting producers.
Schema Evolution requires careful consideration as events persist over time. Event schemas must evolve backward-compatibly since old events remain in logs and consumers might process historical events. Adding optional fields maintains compatibility, while removing fields or changing types breaks existing consumers.
The push model delivers events to consumers as they arrive, contrasting with polling architectures where consumers repeatedly check for new data. This reduces latency and resource consumption compared to busy-waiting or periodic polling.
# Event structure
class OrderPlacedEvent
attr_reader :event_id, :occurred_at, :aggregate_id, :data
def initialize(order)
@event_id = SecureRandom.uuid
@occurred_at = Time.now
@aggregate_id = order.id
@data = {
order_id: order.id,
user_id: order.user_id,
items: order.items.map(&:to_h),
total: order.total
}
end
def event_type
'order.placed'
end
end
# Producer
class Order
def place!
transaction do
update!(status: 'placed')
event = OrderPlacedEvent.new(self)
EventBus.publish(event)
end
end
end
Ruby Implementation
Ruby applications implement EDA through various gems and patterns, ranging from lightweight in-process event buses to distributed message broker integrations.
Wisper provides a simple pub-sub implementation for Ruby objects. It works within a single process, making it suitable for Rails applications that need event-driven internal communication without external dependencies.
# Wisper setup
class Order
include Wisper::Publisher
def place!
update!(status: 'placed')
publish(:order_placed, self)
end
end
class InventoryListener
def order_placed(order)
order.items.each do |item|
InventoryItem.decrement(item.sku, item.quantity)
end
end
end
class EmailListener
def order_placed(order)
OrderMailer.confirmation(order).deliver_later
end
end
# Subscribe listeners
inventory_listener = InventoryListener.new
email_listener = EmailListener.new
Order.subscribe(inventory_listener)
Order.subscribe(email_listener)
Wisper executes subscribers synchronously by default, but supports asynchronous execution through ActiveJob integration. The gem handles subscriber registration, event broadcasting, and error isolation between subscribers.
Bunny provides a Ruby client for RabbitMQ, enabling distributed event processing across multiple services. RabbitMQ implements AMQP protocol and offers exchange types for various routing patterns.
require 'bunny'
# Producer
class OrderEventProducer
def initialize
@connection = Bunny.new(hostname: 'localhost')
@connection.start
@channel = @connection.create_channel
@exchange = @channel.topic('orders', durable: true)
end
def publish_order_placed(order)
event = {
event_id: SecureRandom.uuid,
event_type: 'order.placed',
occurred_at: Time.now.iso8601,
data: order.as_json
}
@exchange.publish(
event.to_json,
routing_key: 'order.placed',
persistent: true,
content_type: 'application/json'
)
end
def close
@connection.close
end
end
# Consumer
class InventoryEventConsumer
def start
connection = Bunny.new(hostname: 'localhost')
connection.start
channel = connection.create_channel
channel.prefetch(10)
exchange = channel.topic('orders', durable: true)
queue = channel.queue('inventory_service', durable: true)
queue.bind(exchange, routing_key: 'order.placed')
queue.subscribe(manual_ack: true, block: true) do |delivery_info, properties, body|
process_event(JSON.parse(body))
channel.ack(delivery_info.delivery_tag)
rescue StandardError => e
channel.nack(delivery_info.delivery_tag, false, true)
logger.error("Failed to process event: #{e.message}")
end
end
private
def process_event(event)
order_data = event['data']
order_data['items'].each do |item|
Inventory.reduce_stock(item['sku'], item['quantity'])
end
end
end
RabbitMQ provides delivery guarantees through acknowledgments. Consumers explicitly acknowledge successful processing, allowing the broker to redeliver messages on failure. The prefetch setting controls how many unacknowledged messages a consumer can receive, implementing backpressure.
ruby-kafka connects Ruby applications to Apache Kafka, a distributed event streaming platform. Kafka stores events in ordered, immutable logs partitioned across multiple brokers for horizontal scaling.
require 'kafka'
# Producer with partitioning
class OrderEventKafkaProducer
def initialize
@kafka = Kafka.new(['localhost:9092'])
@producer = @kafka.async_producer(
delivery_threshold: 100,
delivery_interval: 10
)
end
def publish_order_placed(order)
event = {
event_id: SecureRandom.uuid,
event_type: 'order.placed',
occurred_at: Time.now.to_i,
data: order.as_json
}
@producer.produce(
event.to_json,
topic: 'orders',
key: order.user_id.to_s,
partition_key: order.user_id.to_s
)
end
def shutdown
@producer.shutdown
end
end
# Consumer with consumer groups
class InventoryEventKafkaConsumer
def start
kafka = Kafka.new(['localhost:9092'])
consumer = kafka.consumer(
group_id: 'inventory-service',
offset_commit_interval: 10
)
consumer.subscribe('orders', start_from_beginning: false)
trap('TERM') { consumer.stop }
consumer.each_message do |message|
event = JSON.parse(message.value)
process_event(event)
rescue StandardError => e
logger.error("Failed to process message: #{e.message}")
# Message will be retried after consumer restart
end
end
private
def process_event(event)
return unless event['event_type'] == 'order.placed'
order_data = event['data']
order_data['items'].each do |item|
Inventory.reduce_stock(item['sku'], item['quantity'])
end
end
end
Kafka's partitioning strategy distributes events across multiple partitions within a topic. Events with the same key route to the same partition, maintaining order for related events. Consumer groups allow multiple instances to process different partitions in parallel, scaling consumption horizontally.
Event Sourcing with Rails Event Store enables storing state changes as a sequence of events rather than current state snapshots.
# Rails Event Store setup
class Order < ApplicationRecord
include AggregateRoot
def place(items, user_id)
apply OrderPlaced.new(data: {
order_id: id,
items: items,
user_id: user_id,
placed_at: Time.now
})
end
def confirm_payment(payment_id)
raise 'Order not placed' unless placed?
apply PaymentConfirmed.new(data: {
order_id: id,
payment_id: payment_id
})
end
private
def apply_order_placed(event)
@placed = true
@items = event.data[:items]
@user_id = event.data[:user_id]
end
def apply_payment_confirmed(event)
@payment_confirmed = true
@payment_id = event.data[:payment_id]
end
def placed?
@placed
end
end
# Command handler
class PlaceOrderHandler
def call(command)
order = Order.new(id: SecureRandom.uuid)
order.place(command.items, command.user_id)
event_store.publish(order.unpublished_events, stream_name: "Order$#{order.id}")
end
private
def event_store
RailsEventStore::Client.new
end
end
Design Considerations
Selecting EDA requires evaluating trade-offs against synchronous architectures. EDA introduces complexity through asynchronous communication, eventual consistency, and distributed debugging challenges. These costs must justify the benefits of loose coupling and independent scaling.
Loose Coupling Benefits emerge when services evolve independently. In a monolithic request-response system, adding new functionality to order processing requires modifying the order service code. With EDA, new consumers subscribe to order events without changing producer code. A new analytics service, fraud detection system, or notification service can process order events by adding a consumer.
This coupling reduction facilitates organizational scaling. Different teams own different consumers, deploying changes independently without coordinating deployments. A team building a recommendation engine subscribes to purchase events without negotiating API contracts with the orders team.
Temporal Independence allows consumers to process events at different speeds. A confirmation email handler processes events within seconds, while a monthly report generator batches events over longer periods. Consumers can fall behind during high load without affecting producers or other consumers. This independence prevents cascading failures where a slow downstream service blocks upstream operations.
Scaling Flexibility improves when load distributes unevenly across components. An e-commerce system might need ten instances processing order validations but only two instances sending emails. EDA allows scaling each consumer independently based on its workload, unlike synchronous calls where the caller and callee scale together.
Eventual Consistency Trade-offs require accepting that system state won't immediately reflect recent events. After placing an order, inventory might not reflect the new quantities immediately. UI code must handle this inconsistency, displaying "order processing" states rather than assuming immediate completion. Applications requiring strong consistency (financial transactions, seat reservations) face additional complexity implementing compensating transactions when eventual consistency causes conflicts.
Debugging Complexity increases as request traces span multiple asynchronous processes. A bug in order processing might originate in event production, message routing, or consumption logic executing minutes later in a different service. Correlation IDs become necessary to trace operations across service boundaries. Logging and monitoring require distributed tracing tools to reconstruct request flows.
Message Ordering Challenges arise when events must process in sequence. Kafka partitions maintain order within a partition, but parallel processing across partitions can reorder events. A user updating their profile twice might have updates processed out of order if routing to different partitions. Solutions include single-partition processing (limiting parallelism), sequence numbers, or idempotent operations that produce correct results regardless of order.
Delivery Guarantees affect system reliability. At-most-once delivery might lose events during failures. At-least-once delivery ensures messages arrive but might deliver duplicates, requiring idempotent consumers. Exactly-once delivery provides the strongest guarantee but often performs poorly. Select guarantees based on business requirements: lost confirmation emails cause minor issues, while lost payment events create serious problems.
Schema Evolution Complexity grows as event schemas change over time. Consumers must handle old event versions persisting in logs. Adding required fields breaks old events, while changing field types requires migration strategies. Event versioning strategies include separate event types per version, version fields within events, or schema registries validating compatibility.
Operational Overhead includes running message brokers, monitoring queue depths, managing consumer lag, and handling poison messages. These infrastructure components require expertise to operate reliably at scale. Simple applications might find this overhead outweighs EDA benefits.
EDA suits applications where different components have different scaling needs, multiple independent reactions to events occur, or audit trails and event replay provide business value. Applications with simple workflows, strong consistency requirements, or small development teams might find synchronous architectures simpler.
Implementation Approaches
Several architectural patterns implement event-driven systems, each with distinct characteristics and use cases.
Message Queue Pattern uses broker-based queues to deliver events to consumers. Producers send messages to named queues, and consumers pull messages for processing. RabbitMQ and Amazon SQS exemplify this pattern.
Message queues provide point-to-point communication where each message delivers to exactly one consumer. Multiple consumer instances compete for messages from the same queue, distributing load across instances. This pattern works for work distribution scenarios where each event requires processing exactly once.
Queue-based systems handle backpressure naturally through queue depth monitoring. When consumers fall behind, queues accumulate messages, providing visibility into lag. Producers can throttle when queues reach capacity, preventing overwhelming consumers.
Dead letter queues capture messages that fail repeatedly, preventing poison messages from blocking queue processing. After exceeding retry attempts, failed messages route to a dead letter queue for manual investigation and reprocessing.
# Message queue pattern with Sneakers (RabbitMQ + ActiveJob)
class OrderProcessor
include Sneakers::Worker
from_queue 'orders',
threads: 10,
prefetch: 20,
timeout_job_after: 30,
retry_timeout: 5000
def work(message)
event = JSON.parse(message)
process_order(event)
ack!
rescue StandardError => e
logger.error("Processing failed: #{e.message}")
reject!
end
private
def process_order(event)
order = Order.find(event['order_id'])
order.process_payment
order.fulfill_items
order.send_confirmation
end
end
Publish-Subscribe Pattern broadcasts events to multiple independent subscribers. A topic or exchange receives events, routing copies to all subscribed consumers. This pattern enables one event to trigger multiple independent reactions.
Topic-based routing filters events based on patterns. RabbitMQ topic exchanges route messages using wildcard patterns, allowing consumers to subscribe to event subsets. A logging service subscribes to all events (#), while an email service subscribes only to user-related events (user.*).
Pub-sub systems maintain subscription state server-side. Consumers register interest in topics, and the broker manages delivering relevant events. Adding new subscribers doesn't affect publishers or existing subscribers, enabling independent service evolution.
Fanout scenarios where many consumers process the same event benefit from pub-sub. An order placement might notify inventory, shipping, analytics, and fraud detection services simultaneously. Each service receives its own copy, processing independently.
Event Streaming Pattern maintains ordered, replayable logs of events. Apache Kafka and Amazon Kinesis implement this pattern, storing events durably and allowing multiple consumers to read from arbitrary positions.
Event streams partition logs for parallel processing while maintaining order within partitions. Each partition functions as an ordered sequence of events, and consumers track their position independently. This architecture scales horizontally by adding partitions and consumer instances.
Stream processing applications transform event streams into derived streams or materialized views. A consumer reads order events, computes running totals, and publishes aggregated revenue events. These transformations compose into processing pipelines.
Event replay capabilities allow rebuilding application state from event history. New analytics consumers can process historical events to populate initial state before consuming real-time events. This pattern supports schema changes and bug fixes that require reprocessing events.
# Event streaming with Karafka
class OrderEventsConsumer < ApplicationConsumer
def consume
messages.each do |message|
event = JSON.parse(message.raw_payload)
case event['event_type']
when 'order.placed'
Analytics.record_order(event['data'])
when 'order.cancelled'
Analytics.cancel_order(event['data'])
end
mark_as_consumed(message)
end
end
end
Event Sourcing Pattern persists all state changes as events rather than storing current state. The event log becomes the source of truth, and current state derives from replaying events.
Event sourcing provides complete audit trails showing how state evolved over time. Financial applications track every balance change, enabling compliance reporting and dispute resolution. Debugging benefits from replaying events to reproduce bugs.
Temporal queries answer questions about past state by replaying events up to a specific timestamp. A system can determine account balances at month-end by processing events before the month boundary.
Snapshots optimize event replay performance. Periodically storing current state snapshots allows rebuilding state from the latest snapshot rather than replaying all historical events. Snapshots function as performance optimizations without affecting correctness.
Commands represent intent to change state, while events represent completed state changes. A PlaceOrder command attempts to place an order, succeeding or failing based on validation. Upon success, an OrderPlaced event persists, recording the state change.
CQRS Pattern separates command operations (writes) from query operations (reads). Commands change state through events, while queries read from optimized materialized views built by consuming events.
Write models optimize for consistency and validation. Commands validate business rules before emitting events. The write model might use complex domain logic and normalized data structures.
Read models optimize for query performance. Event consumers build denormalized views tailored to specific query patterns. A product listing page reads from a materialized view containing pre-joined product and inventory data, avoiding expensive joins at query time.
Multiple read models can exist for different query patterns. An analytics database, a search index, and a cache each consume the same events to build views optimized for their access patterns. These views remain eventually consistent with the write model.
Common Patterns
Several recurring patterns solve specific challenges in event-driven systems.
Saga Pattern coordinates distributed transactions across multiple services through event choreography or orchestration. Unlike two-phase commit protocols requiring locks across services, sagas use compensating transactions to maintain consistency.
Choreography-based sagas distribute coordination logic across services. Each service listens for events, performs its work, and publishes success or failure events. Other services react to these events, progressing or rolling back the saga.
# Choreography saga for order processing
class OrderService
def place_order(order_params)
order = Order.create!(order_params)
EventBus.publish('order.placed', order: order)
order
end
end
class PaymentService
def on_order_placed(event)
order = event.data[:order]
payment = process_payment(order)
if payment.successful?
EventBus.publish('payment.completed', order: order, payment: payment)
else
EventBus.publish('payment.failed', order: order, reason: payment.error)
end
end
end
class OrderService
def on_payment_failed(event)
order = Order.find(event.data[:order].id)
order.cancel!
EventBus.publish('order.cancelled', order: order)
end
end
class InventoryService
def on_payment_completed(event)
order = event.data[:order]
reserve_inventory(order.items)
EventBus.publish('inventory.reserved', order: order)
rescue InsufficientInventoryError => e
EventBus.publish('inventory.reservation_failed', order: order)
end
end
class PaymentService
def on_inventory_reservation_failed(event)
refund_payment(event.data[:order])
EventBus.publish('payment.refunded', order: event.data[:order])
end
end
Orchestration-based sagas centralize coordination logic in a saga coordinator. The coordinator sends commands to services and handles their responses, maintaining saga state.
# Orchestration saga
class OrderSagaOrchestrator
def execute(order_id)
saga = OrderSaga.create!(order_id: order_id, state: 'started')
begin
process_payment(saga)
reserve_inventory(saga)
ship_order(saga)
complete_saga(saga)
rescue SagaCompensationNeeded => e
compensate(saga)
end
end
private
def process_payment(saga)
saga.update!(state: 'processing_payment')
result = PaymentService.charge(saga.order_id)
saga.update!(payment_id: result.payment_id, state: 'payment_completed')
rescue PaymentError => e
saga.update!(state: 'payment_failed')
raise SagaCompensationNeeded
end
def reserve_inventory(saga)
saga.update!(state: 'reserving_inventory')
InventoryService.reserve(saga.order_id)
saga.update!(state: 'inventory_reserved')
rescue InventoryError => e
saga.update!(state: 'inventory_failed')
raise SagaCompensationNeeded
end
def compensate(saga)
refund_payment(saga) if saga.payment_id
release_inventory(saga) if saga.inventory_reserved?
saga.update!(state: 'compensated')
end
end
Inbox Pattern ensures exactly-once message processing by storing incoming messages in a database table before processing. The message handler and business logic execute in the same transaction, preventing duplicate processing.
class InboxMessageHandler
def handle(message)
InboxMessage.transaction do
inbox_entry = InboxMessage.create!(
message_id: message.id,
payload: message.body,
status: 'received'
)
process_message(inbox_entry)
inbox_entry.update!(status: 'processed')
end
rescue ActiveRecord::RecordNotUnique
# Duplicate message, already processed
logger.info("Skipping duplicate message: #{message.id}")
end
private
def process_message(inbox_entry)
payload = JSON.parse(inbox_entry.payload)
Order.create!(payload['order_data'])
end
end
Outbox Pattern ensures atomic publishing of events during database transactions. Rather than publishing events directly, handlers write events to an outbox table in the same transaction as business logic. A separate process polls the outbox and publishes events.
class OrderService
def place_order(params)
Order.transaction do
order = Order.create!(params)
Outbox.create!(
event_type: 'order.placed',
aggregate_id: order.id,
payload: {
order_id: order.id,
items: order.items.as_json,
total: order.total
}.to_json
)
order
end
end
end
class OutboxPublisher
def run
loop do
publish_batch
sleep(1)
end
end
private
def publish_batch
messages = Outbox.where(published: false).limit(100)
messages.each do |message|
EventBus.publish(message.event_type, JSON.parse(message.payload))
message.update!(published: true, published_at: Time.now)
end
end
end
Event Notification Pattern notifies external systems of state changes without including full entity state in events. Consumers receive notification events and query the source system for current state if needed.
This pattern reduces event payload size and avoids sending sensitive data through event channels. Events contain only identifiers and basic metadata, while consumers make API calls to retrieve complete data.
Event-Carried State Transfer Pattern includes full entity state in events, allowing consumers to maintain local copies without querying the source system. This trades larger event sizes for reduced inter-service communication and improved consumer autonomy.
Events contain complete entity snapshots at the time of change. Consumers build read replicas by applying state updates from events, avoiding queries to the source system during request handling.
Priority Queue Pattern processes high-priority events before lower-priority events. Events include priority metadata, and consumers process from priority-ordered queues or sort events before processing.
class PriorityEventConsumer
def consume
high_priority = fetch_messages(queue: 'orders.high_priority', limit: 10)
normal_priority = fetch_messages(queue: 'orders.normal_priority', limit: 5)
(high_priority + normal_priority).each do |message|
process_message(message)
end
end
end
Claim Check Pattern stores large payloads externally and includes only references in events. This prevents message size limits on event channels and improves throughput.
Events contain storage keys or URLs pointing to payload data. Consumers retrieve payload data from object storage when processing events. This pattern applies to events with large attachments, files, or binary data.
Real-World Applications
Event-driven architecture powers various production systems across different domains.
Microservices Architectures use events for inter-service communication, allowing services to remain loosely coupled. An e-commerce platform might decompose into order, inventory, payment, and notification services. When users place orders, the order service publishes OrderPlaced events. Inventory, payment, and notification services each consume these events independently, performing their responsibilities without tight coupling to the order service.
This decomposition enables independent deployment cycles. The notification service team can deploy changes to email templates without coordinating with other teams. New services can integrate by subscribing to existing events without modifying upstream services.
Circuit breakers and retry policies protect against cascading failures. If the payment service experiences downtime, messages queue for processing when the service recovers. Order placement doesn't fail because payment processing happens asynchronously.
Audit Logging and Compliance benefit from event sourcing's immutable event log. Financial services track every account transaction as events, providing complete audit trails for regulatory compliance. Events record who performed actions, when they occurred, and what data changed.
Compliance reporting queries the event store to answer questions about historical state. A compliance officer investigating suspicious activity reviews all events related to an account, reconstructing the sequence of operations that led to the current state.
Event retention policies balance compliance requirements with storage costs. Some regulations require maintaining financial records for years, while other event types can expire after shorter periods.
Real-Time Analytics and Monitoring stream events to analytics systems for immediate processing. E-commerce platforms track user behavior events (page views, searches, cart additions) in real time to detect trends, identify issues, and personalize recommendations.
Stream processing frameworks consume events, compute aggregations, and trigger alerts. A fraud detection system analyzes purchase events in real time, flagging suspicious patterns like unusual purchase amounts or geographic anomalies.
Clickstream data flows through event pipelines to data warehouses, enabling analysts to query user behavior patterns. Marketing teams A/B test features by comparing event distributions across user cohorts.
IoT and Telemetry Systems collect events from distributed devices, processing millions of events per second. Smart building systems stream temperature, occupancy, and energy consumption events to optimize climate control and detect equipment failures.
Time-series databases store device telemetry events, enabling historical analysis and anomaly detection. Predictive maintenance models process sensor events to predict equipment failures before they occur.
CQRS Applications separate write and read models using events. A social media platform stores posts, comments, and likes as events in a write model optimized for consistency. Event consumers build materialized views optimized for feed generation, trending topic detection, and user profile display.
Read models scale independently based on query load. The platform might run one instance of the write model and fifty instances serving read queries from denormalized views. Adding read model instances doesn't affect write throughput.
Distributed Tracing and Observability correlates events across services using trace IDs. Each event includes correlation metadata, allowing distributed tracing systems to reconstruct request flows across microservices boundaries.
Service mesh technologies like Istio automatically inject trace IDs into events, providing visibility into request latency, error rates, and service dependencies without requiring application code changes.
Background Job Processing uses event-driven patterns to offload work from request handlers. Web applications publish events for time-consuming operations like image processing, report generation, or email delivery. Background workers consume these events, freeing web servers to handle more requests.
Worker pools scale based on queue depth metrics. During peak load, additional workers process the backlog, while fewer workers run during quiet periods. This elasticity optimizes resource utilization and cost.
Notification Systems aggregate events and deliver notifications across multiple channels. A user might receive notifications via email, SMS, push notifications, and in-app alerts based on subscription preferences. The notification service consumes various event types (mentions, replies, updates) and routes notifications to appropriate channels.
Notification batching prevents overwhelming users. Instead of sending immediate notifications for every event, the system batches related events into digest notifications sent at configured intervals.
Data Synchronization keeps read replicas consistent across geographic regions. A global application publishes events to regional data centers, where consumers update local databases. Users query their nearest data center, reducing latency while maintaining eventual consistency across regions.
Conflict resolution strategies handle concurrent updates to the same data in different regions. Last-write-wins, vector clocks, or application-specific merge logic resolves conflicts based on business requirements.
Reference
Core Components
| Component | Description | Responsibilities |
|---|---|---|
| Event Producer | Generates and publishes events | Detects state changes, creates event payloads, publishes to channels |
| Event Consumer | Subscribes to and processes events | Filters relevant events, executes business logic, manages acknowledgments |
| Event Channel | Transports events between producers and consumers | Routes messages, provides delivery guarantees, handles backpressure |
| Event Store | Persists events for replay and audit | Maintains immutable event log, supports temporal queries, enables replay |
| Schema Registry | Manages event schema definitions | Validates schema compatibility, versions schemas, provides discovery |
Delivery Guarantees
| Guarantee Type | Behavior | Use Cases |
|---|---|---|
| At-most-once | Event delivered zero or one time, may lose events | Non-critical notifications, telemetry |
| At-least-once | Event delivered one or more times, may duplicate | Most business events with idempotent consumers |
| Exactly-once | Event delivered exactly once | Financial transactions, inventory management |
Pattern Comparison
| Pattern | Coupling | Consistency | Complexity | Scalability |
|---|---|---|---|---|
| Message Queue | Low | Eventual | Medium | High |
| Pub-Sub | Very Low | Eventual | Medium | Very High |
| Event Streaming | Low | Eventual | High | Very High |
| Event Sourcing | Low | Eventual | Very High | High |
| CQRS | Very Low | Eventual | High | Very High |
Ruby Gem Selection
| Gem | Pattern | Broker | Complexity | Use When |
|---|---|---|---|---|
| Wisper | Pub-Sub | In-process | Low | Single application, simple events |
| Bunny | Message Queue / Pub-Sub | RabbitMQ | Medium | Multiple services, reliable delivery |
| ruby-kafka | Event Streaming | Kafka | High | High throughput, event replay needed |
| Karafka | Event Streaming | Kafka | Medium | Kafka with Rails integration |
| Sneakers | Message Queue | RabbitMQ | Medium | Background job processing |
| Rails Event Store | Event Sourcing | Database | High | Complete audit trails, temporal queries |
Event Design Guidelines
| Aspect | Recommendation | Example |
|---|---|---|
| Naming | Past tense verbs | OrderPlaced, PaymentCompleted |
| Payload | Minimal required data | IDs and changed fields, not entire objects |
| Schema | Backward compatible evolution | Add optional fields, avoid removing fields |
| Metadata | Include correlation IDs | request_id, user_id, timestamp |
| Size | Keep small, use claim check for large data | Under 1MB, reference external storage |
Consumer Configuration
| Setting | Purpose | Typical Value |
|---|---|---|
| Prefetch Count | Limit unacknowledged messages | 10-100 |
| Processing Timeout | Maximum processing time | 30-300 seconds |
| Retry Attempts | Failed message retry count | 3-5 |
| Dead Letter Queue | Failed message destination | Separate queue for investigation |
| Consumer Group | Parallel processing coordination | Unique per service type |
Ordering Strategies
| Strategy | Ordering Guarantee | Throughput | Complexity |
|---|---|---|---|
| Single Partition | Total order | Low | Low |
| Partition Key | Per-key order | High | Medium |
| Sequence Numbers | Application-level order | High | High |
| No Guarantee | None | Very High | Low |