Amazon EventBridge: Building Reliable Event-Driven Architectures on AWS

Every application generates events: an order was placed, a user signed up, a payment was processed. In a monolith these are function calls. In a distributed system they become the connective tissue between services. The naive approach is direct API calls between services, which creates tight coupling, cascading failures, and deployment dependencies that constrain how fast you can ship.

EventBridge is the managed event bus that decouples producers from consumers. A service publishes an event. EventBridge routes it to every subscriber that matches the event pattern. Subscribers have no idea who else is listening. Producers have no idea what happens downstream. In this article I will walk through building a production event-driven architecture with EventBridge buses, rules, Pipes, and the dead-letter patterns that keep things reliable.

Event Buses and Custom Events

resource "aws_cloudwatch_event_bus" "main" {
  name = "production-events"

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

resource "aws_schemas_registry" "main" {
  registry_name = "production-event-schemas"
  description   = "Schema registry for production events"
}

resource "aws_schemas_discoverer" "main" {
  source_arn  = aws_cloudwatch_event_bus.main.arn
  description = "Auto-discover schemas from events"
}

Use a custom event bus rather than the default bus for application events. The default bus receives AWS service events and mixing application events into it creates filtering noise. Separate buses give you independent IAM policies, resource-based policies for cross-account access, and cleaner monitoring. The schema discoverer automatically captures event schemas from events published to the bus, building a self-documenting schema registry your teams can use for code generation.

Publishing Events

import boto3
import json
from datetime import datetime

events = boto3.client("events", region_name="us-east-1")

def publish_event(event_type: str, detail: dict, source: str = "com.yourcompany.orders"):
    response = events.put_events(
        Entries=[{
            "Source":       source,
            "DetailType":   event_type,
            "Detail":       json.dumps({
                **detail,
                "eventVersion": "1.0",
                "timestamp":    datetime.utcnow().isoformat()
            }),
            "EventBusName": "production-events"
        }]
    )

    if response["FailedEntryCount"] > 0:
        failed = response["Entries"][0]
        raise RuntimeError(f"Event publish failed: {failed.get('ErrorCode')} - {failed.get('ErrorMessage')}")

def order_placed(order_id: str, customer_id: str, total: float, items: list):
    publish_event(
        event_type  = "OrderPlaced",
        detail      = {
            "orderId":    order_id,
            "customerId": customer_id,
            "total":      total,
            "itemCount":  len(items)
        }
    )

Always check FailedEntryCount on the put_events response. A 200 HTTP response from EventBridge does not guarantee all events were accepted. Individual entries can fail while the batch succeeds at the HTTP level. The response includes per-entry error codes for failed entries. Treat any non-zero FailedEntryCount as an error and retry or alert accordingly.

Rules and Targets

resource "aws_cloudwatch_event_rule" "order_placed" {
  name           = "order-placed"
  event_bus_name = aws_cloudwatch_event_bus.main.name
  description    = "Routes OrderPlaced events to downstream processors"

  event_pattern = jsonencode({
    source      = ["com.yourcompany.orders"]
    detail-type = ["OrderPlaced"]
    detail = {
      total = [{ numeric = [">=", 100] }]
    }
  })
}

resource "aws_cloudwatch_event_target" "fulfillment_queue" {
  rule           = aws_cloudwatch_event_rule.order_placed.name
  event_bus_name = aws_cloudwatch_event_bus.main.name
  target_id      = "fulfillment-sqs"
  arn            = aws_sqs_queue.fulfillment.arn

  dead_letter_config {
    arn = aws_sqs_queue.eventbridge_dlq.arn
  }

  retry_policy {
    maximum_event_age_in_seconds = 3600
    maximum_retry_attempts       = 3
  }
}

resource "aws_cloudwatch_event_target" "analytics_lambda" {
  rule           = aws_cloudwatch_event_rule.order_placed.name
  event_bus_name = aws_cloudwatch_event_bus.main.name
  target_id      = "analytics-lambda"
  arn            = aws_lambda_function.order_analytics.arn

  input_transformer {
    input_paths = {
      orderId    = "$.detail.orderId"
      customerId = "$.detail.customerId"
      total      = "$.detail.total"
    }
    input_template = <<-EOT
      {
        "order_id":    "",
        "customer_id": "",
        "total":       ,
        "event_time":  ""
      }
    EOT
  }

  dead_letter_config {
    arn = aws_sqs_queue.eventbridge_dlq.arn
  }

  retry_policy {
    maximum_event_age_in_seconds = 3600
    maximum_retry_attempts       = 3
  }
}

The event pattern filter on total >= 100 means only orders above that threshold trigger this rule. EventBridge evaluates patterns at the bus level before invoking any target, which means your downstream services do not receive events they cannot process. This is more efficient than invoking every target and letting each one filter in application code.

The input_transformer reshapes the event before delivering it to the Lambda target. Your Lambda receives a clean, minimal payload rather than the full EventBridge envelope. This decouples your Lambda function from the EventBridge event structure, making it easier to test in isolation and reuse across different event sources.

Dead-letter queues on both targets capture events that EventBridge cannot deliver after exhausting retries. Without a DLQ, failed deliveries are silently dropped. Monitor the DLQ with a CloudWatch alarm on ApproximateNumberOfMessagesVisible and treat any message in the DLQ as an incident requiring investigation.

EventBridge Pipes for Enrichment

resource "aws_pipes_pipe" "order_enrichment" {
  name     = "order-enrichment-pipe"
  role_arn = aws_iam_role.pipes.arn

  source = aws_sqs_queue.raw_orders.arn

  source_parameters {
    sqs_queue_parameters {
      batch_size                         = 10
      maximum_batching_window_in_seconds = 5
    }
  }

  enrichment = aws_lambda_function.order_enricher.arn

  enrichment_parameters {
    input_template = <<-EOT
      {
        "orderId":    "",
        "customerId": ""
      }
    EOT
  }

  target = aws_cloudwatch_event_bus.main.arn

  target_parameters {
    event_bridge_event_bus_parameters {
      detail_type = "EnrichedOrderPlaced"
      source      = "com.yourcompany.orders.enriched"
    }
  }
}

Pipes connect a source directly to a target with an optional enrichment step. In this pattern, raw orders arrive in SQS, the enrichment Lambda adds customer data and product details from your database, and the enriched event is published to EventBridge for downstream processing. This replaces a Lambda function that manually polled SQS, called another service, and published to EventBridge, with a fully managed pipeline that handles batching, retries, and dead-lettering automatically.

Closing Thoughts

EventBridge gives you decoupled event routing without managing message broker infrastructure. The schema registry and schema discoverer give your teams a shared contract for event formats. Pipes remove the glue Lambda functions that most event pipelines accumulate.

Always configure dead-letter queues on your targets. Always check FailedEntryCount when publishing. Monitor your DLQs with alarms. Use input transformers to keep your downstream services decoupled from EventBridge envelope structure. These four practices make the difference between an event-driven architecture that is reliable in production and one that silently drops events under failure conditions.

Enjoy the cloud.

Osama


#AWS #EventBridge #EventDrivenArchitecture #CloudArchitecture #Terraform #InfrastructureAsCode #AmazonWebServices #SolutionsArchitect #CloudNative #CloudComputing #Microservices #BackendEngineering #Serverless #TechBlog #CloudInfrastructure #SQS #Lambda #SchemaRegistry #Decoupling #DevOps

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.