← Workshops Β· Part 7 Β· Event-Driven

Event-Driven Development with Amazon EventBridge

Let services react to each other without calling each other. Naming, timing, reliability, and turning your event stream into analytics β€” on Amazon EventBridge.

πŸ“ Test yourself ↓
22 min readEvent-DrivenRead or present

What you'll learn

How to build event-driven services on Amazon EventBridge β€” the pattern JunctionNet and CargoNaut use so services can react to each other without ever calling each other directly. A producer publishes a fact; any number of consumers subscribe by pattern and run in their own Lambda. The producer never knows they exist.

🧭

The one-line idea: instead of "service A calls service B", service A announces "this happened" and B (and C, and a dashboard, and an archive) decide for themselves whether they care.

πŸ”—

This builds on Part 5. There we published a single NoteCreated event from one SAM stack. Here we make events a first-class design tool across services β€” naming, timing, reliability, and turning the event stream into analytics.

πŸŒ“

Python & Node.js. The publisher and consumer code blocks below have a Python / Node.js toggle β€” pick your runtime. The SAM wiring is identical; only the function's Runtime (python3.13 or nodejs20.x) and Handler differ.

The mental model

Four moving parts, and only four. Learn these and the rest is detail:

producer  ──put_events──▢  event bus  ──rule (pattern match)──▢  target (consumer)
                              β”‚                                    β”œβ”€β–Ά Lambda  (react)
                              β”‚                                    β”œβ”€β–Ά SQS     (buffer + retry)
                              β”‚                                    β”œβ”€β–Ά Firehose β–Ά OpenSearch (analytics)
                              β”‚                                    └─▢ another bus / API
  β–² one bus per environment (e.g. Platform-Apps-events-dev)
  β–² a producer knows the bus β€” never the consumers
πŸ“£

Producer

Publishes a domain event after something happens. Fire-and-forget.

🚌

Event bus

A named router. One per stage; every event lands here first.

🎯

Rule

A pattern on source / detail-type that forwards matching events to targets.

πŸ‘‚

Consumer

The target β€” a Lambda, queue, or stream that does the follow-up work.

Anatomy

What an event actually looks like

You control four fields when you publish; EventBridge stamps the rest (id, time, account, region). Rules match on the top-level envelope β€” mainly source and detail-type:

{
  // --- you set these ---
  "EventBusName": "Platform-Apps-events-dev",
  "Source":       "cargonaut.shipment.router",   // who emitted it
  "DetailType":   "ShipmentDelivered",           // what happened (a fact)
  "Detail": {                                    // the payload (your JSON)
    "shipment_id": "shp_123",
    "org_id": "org_abc",
    "occurred_at": "2026-07-12T10:04:00Z"
  },
  // --- EventBridge adds: id, time, region, account, resources ---
}
Best practice Β· 1

Naming the source

source answers "who emitted this?" Use a stable, dotted, lowercase namespace β€” never a display name or something that changes. The platform convention:

# {platform}.{service}.{module}   β€” dotted, lowercase, stable
cargonaut.shipment.router
cargonaut.document.extractor
blueprint.events_management.publisher
audit.import
⚠️

Don't encode data in the source. cargonaut.shipment.org_abc is an anti-pattern β€” org/tenant belongs in Detail, not the routing key. The source is a fixed set of values, not a high-cardinality field.

Best practice Β· 2

Naming the detail-type

detail-type answers "what happened?" The rule that makes an event-driven system sane: events are facts, in the past tense β€” {Entity}{PastTenseVerb}, PascalCase.

Good (a fact)Bad (a command / vague)Why
ShipmentDeliveredDeliverShipmentAn event reports the past; a command tells someone to act. Publishing commands re-couples you.
InvoiceExtractedinvoice_updatePascalCase + specific verb. update forces every consumer to re-inspect the payload to learn what changed.
DocumentClassifiedDocEventOne type = one meaning. A catch-all type defeats pattern-matching.
🧾

Past tense is the discipline. If you can't name it in the past tense, it's probably a command, not an event β€” and the caller should own that work, not broadcast it. "Deliver this" is a request to one owner; "It was delivered" is a fact anyone may react to.

Versioning

Payloads evolve. Add fields freely (consumers ignore unknowns); for a breaking change, publish a new type (ShipmentDeliveredV2) and retire the old one once no rule matches it β€” never silently repurpose an existing type.

Best practice Β· 3

When & where to publish

The single most common bug in event-driven code is publishing at the wrong moment. The rule: publish a fact only after the state change is durably committed, from the service layer, off the request's critical path.

# application/services.py β€” publish AFTER the write succeeds
def deliver_shipment(self, shipment_id: str) -> Shipment:
    shipment = self.repository.mark_delivered(shipment_id)   # 1. commit the fact
    self.event_bus.publish(                                  # 2. then announce it
        "ShipmentDelivered",
        {"shipment_id": shipment.id, "org_id": shipment.org_id},
    )
    return shipment
// application/services.js β€” publish AFTER the write succeeds
import { EventBridgeClient, PutEventsCommand } from "@aws-sdk/client-eventbridge";
const eb = new EventBridgeClient({});

export async function deliverShipment(shipmentId) {
  const shipment = await repository.markDelivered(shipmentId);   // 1. commit the fact
  await eb.send(new PutEventsCommand({                           // 2. then announce it
    Entries: [{
      EventBusName: process.env.EVENT_BUS,
      Source: "cargonaut.shipment.router",
      DetailType: "ShipmentDelivered",
      Detail: JSON.stringify({ shipmentId: shipment.id, orgId: shipment.orgId }),
    }],
  }));
  return shipment;
}
⚠️

The dual-write caveat. "Write DB, then put_events" is two operations β€” a crash between them drops the event. For most flows that's acceptable (log & alert). When you truly can't lose an event, use the transactional outbox: write the event to a table in the same DB transaction, then a separate process relays it to EventBridge.

Best practice Β· 4

Subscribing: rules & patterns

A consumer declares an event pattern β€” EventBridge only invokes it for matching events. In SAM this is an EventBridgeRule event source; SAM creates the rule and the invoke permission:

# template.yaml β€” a consumer that only wakes for two shipment facts
OnShipmentEventFn:
  Type: AWS::Serverless::Function
  Properties:
    Handler: shipment.handlers.event_bus.on_shipment.handler
    Events:
      ShipmentFacts:
        Type: EventBridgeRule
        Properties:
          EventBusName: !Ref PlatformBus
          Pattern:
            source: [ cargonaut.shipment.router ]
            detail-type: [ ShipmentDelivered, ShipmentCancelled ]
Best practice Β· 5

Make it reliable

EventBridge delivers at least once β€” occasionally twice, occasionally out of order. Design consumers for that:

πŸ”

Idempotent

Key side-effects on the event id (or a business key) so a re-delivery is a no-op.

πŸͺ€

DLQ

Attach a dead-letter queue to the rule/target so a poison event is captured, not lost.

♻️

Loop-safe

If a consumer publishes, carry a hop-count in detail so A→B→A can't run forever.

πŸ“¦

Buffer with SQS

Rule β†’ SQS β†’ Lambda smooths spikes and gives you retries + batch control.

🧩

Order isn't guaranteed. Don't assume ShipmentCreated arrives before ShipmentDelivered. Make handlers tolerant (upsert, check current state) rather than assuming a sequence.

Setup

Wiring event β†’ SQS β†’ Lambda

A rule can target a Lambda directly (as above), but for anything bursty or rate-sensitive you put an SQS queue in between: the rule delivers to the queue, and the Lambda drains it in batches. The queue becomes a durable buffer β€” it absorbs spikes, gives you real retry control, and hands poison messages to a dead-letter queue.

event ──rule──▢ SQS queue ──batch──▢ Lambda
                   └──(after N failed receives)──▢ dead-letter queue

The SAM template β€” four pieces

# template.yaml
Resources:
  # 1. the buffer queue + a dead-letter queue for poison messages
  ShipmentDLQ:
    Type: AWS::SQS::Queue
    Properties: { MessageRetentionPeriod: 1209600 }   # 14 days
  ShipmentQueue:
    Type: AWS::SQS::Queue
    Properties:
      VisibilityTimeout: 180                # β‰₯ ~6Γ— the Lambda timeout
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt ShipmentDLQ.Arn
        maxReceiveCount: 5                  # give up after 5 tries β†’ DLQ

  # 2. an EventBridge rule that delivers matching events TO the queue
  ShipmentRule:
    Type: AWS::Events::Rule
    Properties:
      EventBusName: !Ref PlatformBus
      EventPattern:
        source: [ cargonaut.shipment.router ]
        detail-type: [ ShipmentDelivered, ShipmentCancelled ]
      Targets:
        - Id: shipment-queue
          Arn: !GetAtt ShipmentQueue.Arn

  # 3. let EventBridge write to the queue (scoped to THIS rule)
  ShipmentQueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues: [ !Ref ShipmentQueue ]
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal: { Service: events.amazonaws.com }
            Action: sqs:SendMessage
            Resource: !GetAtt ShipmentQueue.Arn
            Condition:
              ArnEquals: { aws:SourceArn: !GetAtt ShipmentRule.Arn }

  # 4. the Lambda drains the queue in batches (SAM makes the event-source mapping)
  OnShipmentEventFn:
    Type: AWS::Serverless::Function
    Properties:
      Handler: shipment.handlers.sqs.on_shipment.handler
      Runtime: python3.13
      Events:
        FromQueue:
          Type: SQS
          Properties:
            Queue: !GetAtt ShipmentQueue.Arn
            BatchSize: 10
            FunctionResponseTypes: [ ReportBatchItemFailures ]   # partial-batch retries

The handler β€” one record at a time, retry only what failed

Each SQS message body is the full EventBridge envelope, so parse record.body to get back detail-type and detail. Use Powertools' batch processor so a single bad message is retried on its own instead of re-running the whole batch:

# src/shipment/handlers/sqs/on_shipment.py
import json
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.batch import (
    BatchProcessor, EventType, process_partial_response)
from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord

logger = Logger(service="ShipmentEvents")
processor = BatchProcessor(event_type=EventType.SQS)

def record_handler(record: SQSRecord):
    event = json.loads(record.body)              # the EventBridge envelope
    detail = event["detail"]
    if event["detail-type"] == "ShipmentDelivered":
        logger.info("delivered", extra={"id": detail["shipment_id"]})
        # …idempotent work; raise to retry ONLY this message

def handler(event, context):
    return process_partial_response(event, record_handler, processor, context)
// src/shipment/handlers/sqs/onShipment.js
export const handler = async (event) => {
  const batchItemFailures = [];
  for (const record of event.Records) {
    try {
      const evt = JSON.parse(record.body);          // the EventBridge envelope
      if (evt["detail-type"] === "ShipmentDelivered") {
        // …idempotent work; throw to retry ONLY this message
      }
    } catch (err) {
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };                       // ReportBatchItemFailures
};
πŸ”€

Direct vs buffered β€” pick per consumer. Rule β†’ Lambda is fine for low-volume, latency-sensitive reactions (EventBridge already retries for 24h and can DLQ). Reach for rule β†’ SQS β†’ Lambda when you need batching, concurrency limits, long/precise retries, or ordering.

The payoff

Extending development with events

This is why the ceremony pays off. Because producers don't know their consumers, new behaviour is a new subscriber β€” you add a feature without opening, testing, or redeploying the service that owns the data.

# Day 1: shipment-service publishes ShipmentDelivered.
# Later, with ZERO changes to shipment-service, add consumers:
ShipmentDelivered
   β”œβ”€β–Ά notification-service   # email the customer
   β”œβ”€β–Ά usage-service          # meter a billable event
   β”œβ”€β–Ά cockpit-service        # close the action card
   └─▢ analytics (OpenSearch) # dashboards + audit trail
Analytics

Forward events to OpenSearch β€” your stream becomes analytics

Add one more consumer to the bus β€” a rule that ships every event to OpenSearch (via Firehose or a small forwarder Lambda). Suddenly the same event stream that drives your services is also a searchable, durable record you can build dashboards on. This is exactly how the platform's event forwarder works.

# a catch-all rule: mirror the whole bus into OpenSearch
Pattern:
  source: [ { "prefix": "cargonaut." } ]     # everything we emit
Target: Firehose β–Ά OpenSearch index  events-{yyyy.mm}

Why this is worth doing:

πŸ“Š

Two jobs, one stream. The event bus is your nervous system (services reacting in real time) and, mirrored to OpenSearch, your memory (analytics, audit, BI). You modelled the events once for behaviour; you get the analytics as a side effect.

A CLAUDE.md for event-driven work

# CLAUDE.md β€” Events

## Conventions
- source: {platform}.{service}.{module}  (dotted, lowercase, STABLE β€” it's a contract)
- detail-type: {Entity}{PastTenseVerb}   (PascalCase, a fact β€” never a command)
- Detail carries ids + org_id + occurred_at; never secrets; add fields, don't repurpose.

## Publishing
- Publish from the SERVICE layer, AFTER the state change commits. Fire-and-forget.
- Never publish from handlers or repositories. One event per real business fact.

## Consuming
- One consumer per concern; match narrowly on source + detail-type.
- Idempotent (key on event id); attach a DLQ; tolerate re-delivery and out-of-order.
- If a consumer publishes, carry a hop-count to prevent loops.

## Analytics
- A catch-all rule mirrors the bus to OpenSearch (Firehose) for audit + dashboards.
  Producers are unaware of it. Never make a service depend on the analytics mirror.

Ask for a feature the event-driven way

State the fact, its name, when it fires, and let the consumer be new β€” don't touch the producer's logic:

> When a shipment is delivered, email the customer β€” event-driven:
  Producer: shipment-service already commits the delivery in
  ShipmentService.deliver_shipment. Add a publish of "ShipmentDelivered"
  (source cargonaut.shipment.router) AFTER the repository write, detail =
  { shipment_id, org_id, occurred_at }. Don't change the delivery logic.
  Consumer: a NEW Lambda in notification-service, EventBridgeRule on
  source=cargonaut.shipment.router, detail-type=[ShipmentDelivered].
  Make it idempotent on the event id and give it a DLQ.
  Show me the template.yaml rule + the handler, and a put-events test.
🧱

Reusable shape: name the fact (past tense) β†’ publish from the service after commit β†’ subscribe with a narrow pattern β†’ idempotent + DLQ β†’ verify with a test event. Same recipe whether the consumer sends an email, meters usage, or forwards to OpenSearch.

Check yourself

Quiz β€” 12 questions

Answer every question, then submit to see your score and the correct answers.

Building event-driven systems on AWS?

JunctionNet ships production event-driven backends every day β€” EventBridge, Lambda, SQS, OpenSearch analytics, Terraform. Reach out for help or a team workshop.

βœ‰οΈ Get in touch ← Back to workshops