Building Event-Driven Architecture with Amazon EventBridge

·

9 min read

Microservices promised us loosely coupled, independently deployable services. But then reality hit: service-to-service HTTP calls created tight coupling, cascading failures, and a distributed monolith. Sound familiar?

The solution isn't more REST APIs - it's event-driven architecture. And in the AWS ecosystem, Amazon EventBridge has emerged as the central nervous system for building truly decoupled, scalable event-driven applications.

In this comprehensive guide, we'll explore how to leverage EventBridge to build production-grade event-driven architectures that scale to millions of events while maintaining loose coupling and resilience.

Why EventBridge? The Event-Driven Evolution

Traditional request-response patterns create tight coupling:

With EventBridge, services become independent event producers and consumers:

Each service operates independently. Adding a new consumer? Just subscribe to the event - no changes to the producer.

EventBridge Core Concepts

Event Buses: The Foundation

EventBridge organizes events into event buses - logical channels that receive and route events. Three types exist:

1. Default Event Bus

  • Automatically created in every AWS account

  • Receives events from AWS services (S3, EC2, RDS, etc.)

  • No manual creation needed

2. Custom Event Bus

  • For your application's custom events

  • Isolate different environments or domains

  • Enable cross-account event routing

3. Partner Event Bus

  • Receives events from SaaS providers

  • Examples: Datadog, Auth0, PagerDuty

  • Automatically created when you set up a partner integration

Event Patterns: Smart Filtering

Event patterns define which events trigger your rules. They use JSON matching:

{
  "source": ["orders.service"],
  "detail-type": ["Order Placed"],
  "detail": {
    "amount": [{ "numeric": [">", 1000] }],
    "region": ["us-east-1", "us-west-2"]
  }
}

This pattern matches only orders over $1,000 in specific regions-precise, declarative filtering.

Targets: Where Events Go

Each rule can route to up to 5 targets simultaneously:

  • Lambda functions

  • Step Functions state machines

  • SQS queues

  • SNS topics

  • Kinesis streams

  • API destinations (HTTP endpoints)

  • CloudWatch log groups

Real-World Architecture: E-Commerce Order Processing

Let's build a production-ready order processing system:

Step 1: Define Your Event Schema

First, establish a consistent event structure:

// events/order-events.ts
export interface OrderPlacedEvent {
  version: '1.0';
  id: string;
  source: 'orders.service';
  'detail-type': 'Order Placed';
  time: string; // ISO 8601
  region: string;
  detail: {
    orderId: string;
    customerId: string;
    items: Array<{
      productId: string;
      quantity: number;
      price: number;
    }>;
    totalAmount: number;
    shippingAddress: {
      street: string;
      city: string;
      state: string;
      zipCode: string;
      country: string;
    };
    metadata: {
      source: 'web' | 'mobile' | 'api';
      promotionCode?: string;
    };
  };
}

Step 2: Create Custom Event Bus

Using AWS CDK:

// lib/event-bus-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as lambda from 'aws-cdk-lib/aws-lambda';

export class OrderEventBusStack extends cdk.Stack {
  public readonly eventBus: events.EventBus;

  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Create custom event bus for orders domain
    this.eventBus = new events.EventBus(this, 'OrderEventBus', {
      eventBusName: 'orders-event-bus',
    });

    // Enable event archiving for replay capability
    new events.Archive(this, 'OrderEventArchive', {
      sourceEventBus: this.eventBus,
      archiveName: 'orders-archive',
      description: 'Archive all order events for 90 days',
      eventPattern: {
        source: ['orders.service'],
      },
      retention: cdk.Duration.days(90),
    });

    // Output the event bus ARN for cross-stack references
    new cdk.CfnOutput(this, 'EventBusArn', {
      value: this.eventBus.eventBusArn,
      exportName: 'OrderEventBusArn',
    });
  }
}

Using AWS SAM:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Resources:
  OrderEventBus:
    Type: AWS::Events::EventBus
    Properties:
      Name: orders-event-bus

  OrderEventArchive:
    Type: AWS::Events::Archive
    Properties:
      ArchiveName: orders-archive
      SourceArn: !GetAtt OrderEventBus.Arn
      Description: Archive all order events for 90 days
      RetentionDays: 90
      EventPattern:
        source:
          - orders.service

Outputs:
  EventBusArn:
    Value: !GetAtt OrderEventBus.Arn
    Export:
      Name: OrderEventBusArn

Step 3: Publish Events from Order Service

# order_service/handler.py
import json
import boto3
import os
from datetime import datetime
from uuid import uuid4
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.utilities.typing import LambdaContext

logger = Logger()
tracer = Tracer()
eventbridge = boto3.client('events')

EVENT_BUS_NAME = os.environ['EVENT_BUS_NAME']

@tracer.capture_lambda_handler
def create_order(event: dict, context: LambdaContext) -> dict:
    """
    Create order and publish event to EventBridge
    """
    try:
        # Parse request body
        body = json.loads(event['body'])

        # Generate order ID
        order_id = f"ORD-{uuid4().hex[:8].upper()}"

        # Validate and process order
        order = {
            'orderId': order_id,
            'customerId': body['customerId'],
            'items': body['items'],
            'totalAmount': sum(item['price'] * item['quantity'] for item in body['items']),
            'shippingAddress': body['shippingAddress'],
            'metadata': {
                'source': body.get('source', 'web'),
                'promotionCode': body.get('promotionCode')
            }
        }

        # Persist to DynamoDB (omitted for brevity)
        # save_order_to_dynamodb(order)

        # Publish event to EventBridge
        publish_order_placed_event(order)

        logger.info("Order created successfully", extra={
            "order_id": order_id,
            "customer_id": order['customerId'],
            "total_amount": order['totalAmount']
        })

        return {
            'statusCode': 201,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({
                'orderId': order_id,
                'message': 'Order created successfully'
            })
        }

    except Exception as e:
        logger.exception("Failed to create order")
        return {
            'statusCode': 500,
            'body': json.dumps({'error': 'Internal server error'})
        }

def publish_order_placed_event(order: dict) -> None:
    """
    Publish OrderPlaced event to EventBridge
    """
    event_detail = {
        'version': '1.0',
        'id': str(uuid4()),
        'source': 'orders.service',
        'detail-type': 'Order Placed',
        'time': datetime.utcnow().isoformat() + 'Z',
        'region': os.environ['AWS_REGION'],
        'detail': order
    }

    response = eventbridge.put_events(
        Entries=[
            {
                'Source': 'orders.service',
                'DetailType': 'Order Placed',
                'Detail': json.dumps(order),
                'EventBusName': EVENT_BUS_NAME
            }
        ]
    )

    # Check for failures
    if response['FailedEntryCount'] > 0:
        logger.error("Failed to publish event", extra={
            "failed_entries": response['Entries']
        })
        raise Exception("Failed to publish event to EventBridge")

    logger.info("Event published successfully", extra={
        "event_id": response['Entries'][0]['EventId']
    })

Step 4: Create Event Rules and Targets

// lib/order-rules-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as sqs from 'aws-cdk-lib/aws-sqs';

export class OrderRulesStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Import the event bus
    const eventBus = events.EventBus.fromEventBusArn(
      this,
      'OrderEventBus',
      cdk.Fn.importValue('OrderEventBusArn')
    );

    // Lambda functions (create these separately)
    const inventoryFunction = lambda.Function.fromFunctionName(
      this,
      'InventoryFunction',
      'inventory-service'
    );

    const emailFunction = lambda.Function.fromFunctionName(
      this,
      'EmailFunction',
      'email-service'
    );

    // Dead letter queue for failed events
    const dlq = new sqs.Queue(this, 'OrderEventsDLQ', {
      queueName: 'order-events-dlq',
      retentionPeriod: cdk.Duration.days(14),
    });

    // Rule 1: All order events to inventory service
    new events.Rule(this, 'OrderToInventoryRule', {
      eventBus: eventBus,
      ruleName: 'order-to-inventory',
      description: 'Route all order events to inventory service',
      eventPattern: {
        source: ['orders.service'],
        detailType: ['Order Placed'],
      },
      targets: [
        new targets.LambdaFunction(inventoryFunction, {
          deadLetterQueue: dlq,
          maxEventAge: cdk.Duration.hours(2),
          retryAttempts: 2,
        }),
      ],
    });

    // Rule 2: High-value orders (>$1000) to email service
    new events.Rule(this, 'HighValueOrderEmailRule', {
      eventBus: eventBus,
      ruleName: 'high-value-order-email',
      description: 'Send email for high-value orders',
      eventPattern: {
        source: ['orders.service'],
        detailType: ['Order Placed'],
        detail: {
          totalAmount: [{ numeric: ['>', 1000] }],
        },
      },
      targets: [
        new targets.LambdaFunction(emailFunction, {
          deadLetterQueue: dlq,
          maxEventAge: cdk.Duration.hours(1),
          retryAttempts: 3,
        }),
      ],
    });

    // Rule 3: Orders with promo codes to analytics
    const analyticsQueue = new sqs.Queue(this, 'AnalyticsQueue', {
      queueName: 'analytics-queue',
    });

    new events.Rule(this, 'PromoCodeAnalyticsRule', {
      eventBus: eventBus,
      ruleName: 'promo-code-analytics',
      description: 'Track orders with promotion codes',
      eventPattern: {
        source: ['orders.service'],
        detailType: ['Order Placed'],
        detail: {
          metadata: {
            promotionCode: [{ exists: true }],
          },
        },
      },
      targets: [
        new targets.SqsQueue(analyticsQueue),
      ],
    });
  }
}

Step 5: Consume Events in Downstream Services

# inventory_service/handler.py
import json
import boto3
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.utilities.typing import LambdaContext
from aws_lambda_powertools.utilities.data_classes import EventBridgeEvent

logger = Logger()
tracer = Tracer()
dynamodb = boto3.resource('dynamodb')
inventory_table = dynamodb.Table('inventory')

@tracer.capture_lambda_handler
def process_order(event: dict, context: LambdaContext) -> dict:
    """
    Process order event from EventBridge
    Reduce inventory quantities for ordered items
    """
    # Parse EventBridge event
    eb_event = EventBridgeEvent(event)

    logger.info("Processing order event", extra={
        "event_id": eb_event.id,
        "source": eb_event.source,
        "detail_type": eb_event.detail_type
    })

    order = eb_event.detail
    order_id = order['orderId']
    items = order['items']

    try:
        # Reserve inventory for each item
        for item in items:
            product_id = item['productId']
            quantity = item['quantity']

            # Update inventory with conditional check
            response = inventory_table.update_item(
                Key={'productId': product_id},
                UpdateExpression='SET availableQuantity = availableQuantity - :qty, reservedQuantity = reservedQuantity + :qty',
                ConditionExpression='availableQuantity >= :qty',
                ExpressionAttributeValues={
                    ':qty': quantity
                },
                ReturnValues='UPDATED_NEW'
            )

            logger.info("Inventory reserved", extra={
                "product_id": product_id,
                "quantity": quantity,
                "new_available": response['Attributes']['availableQuantity']
            })

        # Publish inventory reserved event
        publish_inventory_event(order_id, 'reserved', items)

        return {
            'statusCode': 200,
            'body': json.dumps({'message': 'Inventory reserved successfully'})
        }

    except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
        logger.error("Insufficient inventory", extra={"order_id": order_id})

        # Publish out-of-stock event
        publish_inventory_event(order_id, 'out-of-stock', items)

        return {
            'statusCode': 409,
            'body': json.dumps({'error': 'Insufficient inventory'})
        }

    except Exception as e:
        logger.exception("Failed to process order")
        raise

def publish_inventory_event(order_id: str, status: str, items: list) -> None:
    """
    Publish inventory status event back to EventBridge
    """
    eventbridge = boto3.client('events')

    eventbridge.put_events(
        Entries=[
            {
                'Source': 'inventory.service',
                'DetailType': f'Inventory {status.title()}',
                'Detail': json.dumps({
                    'orderId': order_id,
                    'status': status,
                    'items': items
                }),
                'EventBusName': 'orders-event-bus'
            }
        ]
    )

Advanced Patterns

Pattern 1: Event Transformation with Input Transformer

Transform event structure before sending to target:

new events.Rule(this, 'TransformRule', {
  eventBus: eventBus,
  eventPattern: {
    source: ['orders.service'],
    detailType: ['Order Placed'],
  },
  targets: [
    new targets.LambdaFunction(processingFunction, {
      event: events.RuleTargetInput.fromObject({
        orderId: events.EventField.fromPath('$.detail.orderId'),
        totalAmount: events.EventField.fromPath('$.detail.totalAmount'),
        timestamp: events.EventField.fromPath('$.time'),
        // Add custom fields
        processedBy: 'event-transformer',
        priority: 'high',
      }),
    }),
  ],
});

Pattern 2: Cross-Account Event Routing

Share events across AWS accounts:

// In Account A (producer)
const accountBEventBus = 'arn:aws:events:us-east-1:222222222222:event-bus/shared-events';

new events.Rule(this, 'CrossAccountRule', {
  eventBus: eventBus,
  eventPattern: {
    source: ['orders.service'],
  },
  targets: [
    new targets.EventBus(
      events.EventBus.fromEventBusArn(this, 'AccountBBus', accountBEventBus)
    ),
  ],
});

// In Account B (consumer) - add permissions
const eventBusPolicy = new events.CfnEventBusPolicy(this, 'CrossAccountPolicy', {
  statementId: 'AllowAccountA',
  eventBusName: eventBus.eventBusName,
  statement: {
    Effect: 'Allow',
    Principal: { AWS: 'arn:aws:iam::111111111111:root' },
    Action: 'events:PutEvents',
    Resource: eventBus.eventBusArn,
  },
});

Pattern 3: Event Replay for Testing

EventBridge archives enable event replay:

# Start replay from archive
aws events start-replay \
  --replay-name order-replay-2025-05 \
  --event-source-arn arn:aws:events:us-east-1:123456789012:event-bus/orders-event-bus \
  --event-start-time '2025-05-01T00:00:00Z' \
  --event-end-time '2025-05-31T23:59:59Z' \
  --destination '{
    "Arn": "arn:aws:events:us-east-1:123456789012:event-bus/test-event-bus",
    "FilterArns": ["arn:aws:events:us-east-1:123456789012:rule/test-rule"]
  }'

Monitoring and Debugging

CloudWatch Metrics

Track these key EventBridge metrics:

// Create CloudWatch dashboard
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';

const dashboard = new cloudwatch.Dashboard(this, 'EventBridgeDashboard', {
  dashboardName: 'order-events-dashboard',
});

dashboard.addWidgets(
  new cloudwatch.GraphWidget({
    title: 'Event Ingestion Rate',
    left: [
      new cloudwatch.Metric({
        namespace: 'AWS/Events',
        metricName: 'Invocations',
        dimensionsMap: {
          RuleName: 'order-to-inventory',
        },
        statistic: 'Sum',
        period: cdk.Duration.minutes(5),
      }),
    ],
  }),
  new cloudwatch.GraphWidget({
    title: 'Failed Invocations',
    left: [
      new cloudwatch.Metric({
        namespace: 'AWS/Events',
        metricName: 'FailedInvocations',
        dimensionsMap: {
          RuleName: 'order-to-inventory',
        },
        statistic: 'Sum',
        period: cdk.Duration.minutes(5),
      }),
    ],
  })
);

Dead Letter Queues

Always configure DLQs for fault tolerance:

# Monitor DLQ for failed events
import boto3
from aws_lambda_powertools import Logger

logger = Logger()
sqs = boto3.client('sqs')

def process_dlq_messages(event, context):
    """
    Process failed events from DLQ
    Alert on-call team and attempt recovery
    """
    for record in event['Records']:
        message_body = json.loads(record['body'])

        logger.error("Event processing failed", extra={
            "original_event": message_body,
            "failure_reason": message_body.get('ErrorMessage'),
            "retry_count": int(record.get('ApproximateReceiveCount', 0))
        })

        # Alert monitoring system
        send_alert_to_pagerduty(message_body)

        # Attempt manual recovery or store for investigation
        store_failed_event_for_analysis(message_body)

Best Practices

  1. Use Schema Registry: Define and version your event schemas

  2. Implement Idempotency: Consumers should handle duplicate events gracefully

  3. Set Appropriate Retry Policies: Configure maxEventAge and retryAttempts

  4. Monitor Dead Letter Queues: Set up alerts for DLQ depth

  5. Use Event Archives: Enable archiving for critical event types

  6. Apply Least Privilege: IAM policies should grant minimal necessary permissions

  7. Test Event Patterns: Validate patterns in EventBridge console before deploying

  8. Version Your Events: Include version field in event detail for schema evolution

Cost Optimization

EventBridge pricing:

  • $1.00 per million events published to custom event bus

  • $0.00 per million events from AWS services to default bus

  • Archives and replays have storage costs ($0.10/GB/month)

Optimize costs by:

  • Using event filtering to reduce unnecessary Lambda invocations

  • Batching events when possible

  • Leveraging SQS queues as targets for buffering

Conclusion

Amazon EventBridge transforms how we build distributed systems on AWS. By embracing event-driven architecture, you achieve:

  • Loose coupling: Services evolve independently

  • Scalability: Handle millions of events per second

  • Resilience: Built-in retry, DLQs, and event archiving

  • Extensibility: Add new consumers without touching producers

Start with a single domain (like orders), establish event schemas, and expand from there. Your future self-debugging a distributed system at 2 AM - will thank you for the decoupling.


Building event-driven architectures? Share your EventBridge patterns and challenges in the comments!