Business Challenge
An e-commerce platform processes orders through a monolithic Order Service. When a customer places an order, the service makes four synchronous HTTP calls: reserve inventory, send a confirmation email, write an analytics event, and trigger fulfilment. Four calls, four failure modes, four teams whose uptime directly affects checkout conversion.
The Notifications team deploys a buggy release on Black Friday. Checkout fails for 40 minutes. The Analytics team's Kinesis stream backs up during a flash sale; every checkout request slows by 800ms waiting for a write that a customer never sees. Adding a fifth consumer β a fraud scoring service β requires a code change in Order Service, a new HTTP client, a new retry policy, and a coordinated deploy with the fraud team.
The team decides to adopt Amazon EventBridge. They add events.PutEvents() at the end of the order handler. Now the order saves to the database, calls Inventory synchronously, calls Notifications synchronously, calls Analytics synchronously β and then also fires an event. They have added a sixth communication channel without removing any of the original five. The blast radius is unchanged.
EventBridge reduces coupling when services that previously received direct calls now only receive events β and the producer has no knowledge of who those consumers are. Adding PutEvents alongside existing synchronous calls is instrumentation, not architecture. The decoupling happens when you replace the calls, not supplement them.
Architecture
EventBridge routes events from producers to consumers through rules on an event bus. The producer calls PutEvents with a structured JSON payload; EventBridge evaluates every rule on the target bus in parallel and delivers matching events to each rule's targets. The producer is finished at the point of PutEvents β it does not wait for consumers, does not know which rules matched, and does not know which targets received the event.
Event bus and event structure
EventBridge has a default event bus that receives events from AWS services (EC2 state changes, S3 object operations, CloudTrail API calls, and more than 200 other sources). For application events, create a custom event bus. Custom buses are isolated from the default bus, support resource-based policies for cross-account access, and have cleaner rule namespacing. One bus per domain boundary β Orders, Auth, Inventory β is a reasonable starting point.
Every event has a fixed envelope: source (who produced it), detail-type (what happened), and detail (the payload, up to 256 KB as JSON). Rules match on source, detail-type, and fields inside detail. The schema of the detail object is yours to define β EventBridge does not validate it unless you enable schema discovery.
Rules and targets
A rule is a filter plus a target list. The filter is an event pattern β a JSON object that specifies values to match on fields in the event envelope. EventBridge evaluates all rules on the bus whenever an event arrives; there is no concept of routing priority. An event that matches three rules is delivered to all three independently.
Each rule can route to up to five targets. Targets include Lambda functions, SQS queues, SNS topics, Kinesis Data Streams, Step Functions state machines, API destinations (arbitrary HTTPS endpoints), and other EventBridge buses. EventBridge delivers events to each target with at-least-once semantics β if delivery fails, EventBridge retries for up to 24 hours before routing to the rule's dead-letter queue (if configured).
Each target can have an input transformer that reshapes the event before delivery. A Lambda function expecting a specific payload schema does not need to know the EventBridge envelope format β the transformer extracts the fields it needs. This lets you evolve the event schema without changing consumer code, and lets consumers with different expected formats subscribe to the same event.
Schema Registry
EventBridge can auto-discover schemas from events flowing through a bus and store them as OpenAPI 3.0 documents. Once discovered, you generate typed code bindings (Java, Python, TypeScript, Go) directly from the console or CLI β no manual schema maintenance. Teams consuming an event import the generated types rather than parsing untyped JSON. Schema versioning is tracked automatically.
Event Archive and Replay
An archive captures all events matching a filter pattern, with configurable retention from 1 day to indefinite. Replay reprocesses archived events through the current rules β useful for backfilling a new consumer, recovering from a consumer bug without needing the producer to resend, or reproducing a production incident in a test environment against real event data.
Dead-Letter Queues
Configure a DLQ (SQS queue) on each target, not on the bus. When EventBridge exhausts retries for a specific target, it routes the failed event to that target's DLQ with metadata about the failure. Each consumer has its own failure queue β a Lambda DLQ filling up does not affect SQS or Kinesis targets on the same rule.
Cross-Account Routing
EventBridge buses accept events from other accounts through resource-based policies. The sending account puts events to a bus ARN in the receiving account; the receiving account applies its own rules. This is the standard pattern for platform teams emitting events (security findings, cost alerts, config drift) that multiple product accounts need to consume without polling a shared API.
Why This Architecture Works
Tight coupling is a deployment problem, not a code problem
When Order Service calls Notifications directly, both teams must coordinate releases. A breaking change in the Notifications API requires Order Service to update before Notifications can deploy. EventBridge inverts this: Notifications subscribes to order.placed events and parses what it needs. Notifications can change its internal data model freely. Order Service can add fields to the event without coordinating with consumers. The constraint that forced joint deploys disappears.
Consumer isolation is structural, not operational
In a synchronous fan-out, a slow consumer slows the producer. An unavailable consumer blocks the producer. EventBridge removes both constraints structurally: PutEvents completes when the event is accepted by EventBridge, not when consumers finish processing. A Lambda target timing out on a slow downstream API does not affect the SQS target or the Kinesis target on the same rule. The retry window (up to 24 hours) and the DLQ give the consumer time to recover without the producer ever knowing there was a problem.
Adding consumers is a consumer-side operation
A fraud scoring team wants to evaluate every order. In the synchronous model, Order Service adds an HTTP call and a new deploy dependency. With EventBridge, the fraud team creates a new rule on the orders bus matching order.placed, points it at their Lambda, and deploys independently. Order Service code does not change. Order Service engineers do not review the change. The blast radius of the fraud team's Lambda crashing is their DLQ, not checkout.
Key Design Decisions
A common mistake is creating one bus per microservice β an OrderService bus, a PaymentService bus, an InventoryService bus. This recreates the coupling in bus form: consumers must know which service bus to subscribe to, and moving logic between services requires migrating bus subscriptions. Bus per domain β Orders, Fulfilment, Platform β keeps the event namespace stable even as services are split, merged, or renamed.
RuleName the bus after the domain, not the producer. If two services both emit events that consumers treat as part of the same business domain, they belong on the same bus. If an event could plausibly come from either service and consumers should not care which, the domain boundary is the right granularity.
An event notification tells consumers something happened: {"source": "orders", "detail-type": "order.placed", "detail": {"orderId": "abc123"}}. A consumer that needs the order contents must call the Orders API to fetch them β now there is a synchronous dependency through the back door. Event-carried state transfer includes the data consumers need: {"orderId": "abc123", "customerId": "...", "items": [...], "total": 142.50}. Consumers are truly independent.
If more than one consumer would need to call the producer's API after receiving the event, add that data to the event payload. The 256 KB limit is generous for most transactional events. For large payloads (document content, images, large arrays), put the object in S3 and include the S3 key in the event β consumers fetch the object directly rather than through the producer.
EventBridge provides at-least-once delivery β duplicates are possible, especially during retries. There is no guaranteed ordering between events: two events published milliseconds apart may arrive at a Lambda target in either order. If your consumer logic is not idempotent (processing an event twice produces a different result than processing it once), EventBridge will cause subtle bugs that are hard to reproduce because they only appear under retry conditions.
Required patternConsumers must be idempotent. The standard approach is to include a unique event ID in the payload and use a DynamoDB conditional write β if the ID already exists in a processed-events table, skip the business logic and return success. For ordered processing (inventory reservation must precede fulfilment), use a Step Functions workflow triggered by the event rather than multiple Lambda consumers on the same event.
EventBridge schema discovery is opt-in per bus. Without it, event schemas live in team wikis, Slack messages, and the institutional memory of the engineer who wrote the producer. Two consumers that both parse the same event diverge silently when the producer changes field names. Schema Registry with auto-discovery gives you a central, versioned source of truth β but discovery only captures schemas of events that actually flowed through the bus, which means a low-volume event type may not have a registered schema when a new consumer team needs it.
Governance patternEnable schema discovery on all custom buses. Supplement it with explicit schema registration for event types that are part of your platform contract β events that cross team or account boundaries. Treat the EventBridge Schema Registry as the API contract between teams: if it is not in the registry, it is not a supported event type.
Tradeoffs and Alternatives
| Approach | Works well when | Breaks down when |
|---|---|---|
| EventBridge custom bus | Fan-out to 2β5 consumers; mixed target types (Lambda, SQS, Step Functions); cross-account event routing; low-to-medium throughput (up to ~10,000 events/sec per bus) | Throughput exceeds 10,000 events/sec sustained (use Kinesis); strict event ordering required; consumer needs to process a stream of related events with stateful aggregation |
| SNS + SQS fan-out | Simple pub/sub with SQS consumer buffering; teams already using SNS; need message filtering with SNS filter policies | Cross-account routing is more complex than EventBridge; no schema registry; no archive/replay; filter logic is less expressive than EventBridge event patterns |
| Kinesis Data Streams | High-throughput ordered event streams (100,000+ events/sec); consumers need to process events in sequence; analytics or ML pipelines requiring replay over a time window | Fan-out to heterogeneous consumer types is more complex; provisioned shard capacity requires planning; EventBridge Pipes can bridge EventBridge β Kinesis for hybrid architectures |
| SQS point-to-point | Single consumer that must process every message exactly once (with deduplication); work queue semantics where the producer does not care about consumer identity | Multiple consumers need the same message (use SNS or EventBridge to fan out to multiple SQS queues); event history or schema documentation required |
Reference: EventBridge Rule with SQS Target and DLQ
A Terraform configuration that creates an EventBridge rule matching order.placed events, routes them to an SQS queue for the Inventory service, and configures a DLQ for failed deliveries:
resource "aws_cloudwatch_event_bus" "orders" {
name = "orders"
}
resource "aws_cloudwatch_event_rule" "order_placed_inventory" {
name = "order-placed-inventory"
event_bus_name = aws_cloudwatch_event_bus.orders.name
description = "Route order.placed events to Inventory SQS queue"
event_pattern = jsonencode({
source = ["com.mycompany.orders"]
detail-type = ["order.placed"]
})
}
resource "aws_sqs_queue" "inventory_dlq" {
name = "inventory-order-events-dlq"
}
resource "aws_sqs_queue" "inventory" {
name = "inventory-order-events"
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.inventory_dlq.arn
maxReceiveCount = 3
})
}
resource "aws_cloudwatch_event_target" "inventory_sqs" {
rule = aws_cloudwatch_event_rule.order_placed_inventory.name
event_bus_name = aws_cloudwatch_event_bus.orders.name
target_id = "InventorySQS"
arn = aws_sqs_queue.inventory.arn
dead_letter_config {
arn = aws_sqs_queue.inventory_dlq.arn
}
}
The rule's DLQ and the SQS queue's own redrive policy serve different failure modes. The rule DLQ captures events that EventBridge could not deliver to the SQS queue (queue unavailable, permissions error). The SQS redrive policy captures messages the Inventory consumer failed to process after receiving them. Both are necessary; neither is redundant.
Closing Thought
Tight coupling is not a code problem. It is a deployment problem, an operational problem, and a team coordination problem. EventBridge solves it structurally β not by making failures invisible, but by making them bounded. A consumer failing affects only that consumer's DLQ, not the producer and not other consumers. A consumer team deploying does not require a joint release with the producer team.
The common mistake is treating EventBridge as a notification layer added on top of an existing synchronous architecture. That pattern doubles the communication channels without reducing the blast radius of any one of them. The useful pattern is substitution: replace the synchronous call with an event, let the consumer subscribe, and resist the urge to add a synchronous fallback "just in case." The fallback recreates the coupling you are trying to remove.
Start with one domain boundary. Pick the downstream call that causes the most deployment friction β the one that forces joint releases, the one whose outages affect upstream conversion, the one where adding a new consumer requires a producer code change. Replace that call with an EventBridge event. Validate the pattern at one boundary before expanding. The architecture compounds; you do not need to do it all at once.
Security β KMS key hierarchy for multi-account encryption: how to design Customer Managed Keys, key policies, and grants so that encryption is a platform service rather than a per-team implementation detail that drifts across accounts.
Official AWS Reference
AWS Documentation β Amazon EventBridge User Guide β covers event buses, rules, targets, event patterns, schema registry, archive and replay, and cross-account event routing. The event pattern syntax reference is particularly useful when building precise rule filters.
Comments