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.
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
- Namespaced & owned β the prefix (
cargonaut,blueprint) makes it obvious which platform/team owns the event, and lets a rule subscribe to a whole domain with a prefix filter. - Stable forever β consumers pattern-match on it. Renaming a
sourcesilently breaks every subscriber. Treat it as a public contract. - Not the AWS
aws.*space β that prefix is reserved for AWS's own events (S3, etc.). Yours is your namespace.
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.
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 |
|---|---|---|
ShipmentDelivered | DeliverShipment | An event reports the past; a command tells someone to act. Publishing commands re-couples you. |
InvoiceExtracted | invoice_update | PascalCase + specific verb. update forces every consumer to re-inspect the payload to learn what changed. |
DocumentClassified | DocEvent | One 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.
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;
}- After commit, not before β publish
ShipmentDeliveredbefore the DB write and a crash leaves consumers acting on something that never happened. - From the service, not the handler or repository β the handler is HTTP glue; the repository only touches data. The service owns the business transaction, so it owns the event.
- Off the critical path β the caller's response shouldn't wait on downstream work. Publish and return; consumers run asynchronously.
- One event per real business fact β not one per function call. If a method does two things, that may be two events (or one β model the domain, not the code).
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.
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 ]
- One consumer per concern β a "notify customer" Lambda and an "update analytics" Lambda are two rules, not one function with an
if. - Match narrowly β subscribe to the exact
detail-types you handle, so you aren't invoked (and billed) for events you'll ignore. - Content filtering β patterns can match inside
detailtoo (prefix, anything-but, numeric ranges), so you filter in the bus, not in code.
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.
Order isn't guaranteed. Don't assume ShipmentCreated arrives before ShipmentDelivered. Make handlers tolerant (upsert, check current state) rather than assuming a sequence.
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
};- Backpressure & batching β the queue absorbs a spike; the Lambda pulls
BatchSizemessages per invoke (fewer, cheaper invocations). Cap the function's reserved concurrency to protect a fragile downstream (a DB, a third-party API). - Real retry control β a failed message returns to the queue and is retried up to
maxReceiveCount, then lands in the DLQ. SetVisibilityTimeoutto at least ~6Γ the function timeout so a slow message isn't delivered twice at once. - Partial-batch failure β
ReportBatchItemFailures+ the Powertools processor return only the failed message ids, so a single poison record doesn't force the whole batch to reprocess. - Need ordering? β use an SQS FIFO queue with a
MessageGroupId(e.g. pershipment_id) so events for one entity process in order, while different entities still run in parallel.
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.
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
- Choreography over orchestration β no central coordinator; each service reacts to facts it cares about. The system's behaviour is the sum of subscriptions.
- Safe to add, safe to remove β deleting a consumer removes a behaviour with no risk to the producer. Try that with a direct function call.
- Parallel teams β the team that owns notifications ships a new rule against an existing event without a PR to the shipment team.
The test of a good event: you can add a brand-new feature by writing only a new consumer + rule. If instead you have to edit the producer to "also call the new thing," the event was modelled as a command.
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:
- A durable audit trail, for free β every fact the business emitted, timestamped and immutable. "What happened to shipment X, and when?" becomes one query.
- Analytics without touching producers β funnels, volumes, latency between events (created β delivered), per-org activity β all derived from the archive. Services stay lean; the analytics are a read-side concern.
- Dashboards & alerting β OpenSearch Dashboards over the event index gives ops real-time throughput and error views; alert when
ExtractionFailedspikes. - Debugging & replay β searching real events beats reading logs. Kept raw, the archive also lets you replay history to backfill a new consumer.
- Decoupled & safe β the forwarder is just another subscriber. If OpenSearch is down, your services don't care; only the analytics mirror lags.
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.
The series: Part 5 Β· Python API on SAM (where events started) and CI/CD with GitHub Actions to ship it. Pair with the frontend from Part 1.