AWS Builder Center

Event-Driven on AWS: Decouple Your App with Amazon EventBridge, SQS, and Lambda Using AWS CDK

A hands-on introduction to event-driven architecture on AWS: publish events to Amazon EventBridge, route them to an SQS queue with a dead-letter queue, process them with Lambda, and define everything as code with the AWS CDK.

Event-driven architecture is one of the most common patterns in modern AWS applications, and for good reason. Instead of one component calling another directly and waiting for an answer, components publish events ("an order was placed") and other components react to them when they are ready. The producer doesn't need to know who is listening, and a slow or failing consumer doesn't take the producer down with it.
In this article we'll build a small but realistic version of that pattern and define it as code with the AWS Cloud Development Kit (CDK).

The architecture

1
2
3
4
5
Producer -> EventBridge (custom bus) -> Rule -> SQS queue -> Lambda
|
(after 3 failed attempts)
v
Dead-letter queue (DLQ)
Each piece has a clear role:
  • Amazon EventBridge receives events and routes them using rules that match on the event's content.
  • Amazon SQS buffers events so the consumer can process them at its own pace.
  • AWS Lambda runs the business logic for each event.
  • A dead-letter queue collects messages that keep failing, so they aren't lost and don't block everything else.
    You could connect EventBridge straight to Lambda, but putting a queue in between gives you buffering, retries, and a safe place for failed messages.

Prerequisites

You'll need an AWS account, the AWS CLI configured with credentials, Node.js, and the CDK CLI:
1
2
3
npm install -g aws-cdk
cdk init app --language typescript
cdk bootstrap
cdk bootstrap prepares your account and Region once so CDK can deploy assets such as Lambda code.

Step 1: Define the infrastructure

In the stack file that cdk init created, define the bus, the queues, the rule, and the consumer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';

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

const bus = new events.EventBus(this, 'OrdersBus', {
eventBusName: 'orders-bus',
});

const dlq = new sqs.Queue(this, 'OrdersDlq', {
retentionPeriod: cdk.Duration.days(14),
});

const queue = new sqs.Queue(this, 'OrdersQueue', {
visibilityTimeout: cdk.Duration.seconds(60),
deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
});

new events.Rule(this, 'OrderPlacedRule', {
eventBus: bus,
eventPattern: {
source: ['com.myapp.orders'],
detailType: ['OrderPlaced'],
},
targets: [new targets.SqsQueue(queue)],
});

const processor = new lambda.Function(this, 'OrderProcessor', {
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'app.handler',
code: lambda.Code.fromAsset('lambda/order-processor'),
timeout: cdk.Duration.seconds(30),
});

processor.addEventSource(new SqsEventSource(queue, { batchSize: 5 }));
}
}
A few details worth noticing:
  • The rule only forwards events whose source and detail-type match the pattern. Everything else on the bus is ignored by this rule.
  • maxReceiveCount: 3 means a message moves to the DLQ after three failed processing attempts.
  • The queue's visibility timeout (60 seconds) is longer than the function timeout (30 seconds). If it were shorter, messages could become visible again while still being processed.

Step 2: Write the consumer

Create lambda/order-processor/app.py:
1
2
3
4
5
6
7
8
9
10
11
12
import json


def handler(event, context):
for record in event["Records"]:
# EventBridge delivers the full event envelope as the SQS message body
envelope = json.loads(record["body"])
order = envelope["detail"]

print(f"Processing order {order['orderId']}")
# Business logic goes here.
# Raising an exception returns the message to the queue for a retry.
Because EventBridge wraps your data in an envelope, the payload you published lives under the detail key.

Step 3: Deploy and publish an event

Deploy the stack:
1
cdk deploy
Then publish a test event from any producer. Here is a short Python example using boto3:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import json
import boto3

events = boto3.client("events")

response = events.put_events(
Entries=[
{
"EventBusName": "orders-bus",
"Source": "com.myapp.orders",
"DetailType": "OrderPlaced",
"Detail": json.dumps({"orderId": "1001", "total": 49.90}),
}
]
)

print("Failed entries:", response["FailedEntryCount"])
Check FailedEntryCount after publishing. put_events can succeed as an API call while individual entries still fail. Then open the Lambda function's CloudWatch logs and you should see the order being processed.
To see the safety net work, make the handler raise an exception and publish another event. After three attempts, the message appears in the dead-letter queue.

Things to get right before you trust it

Event-driven systems are flexible, but they come with their own rules:
  • Expect duplicates. SQS standard queues deliver messages at least once, so your consumer may occasionally see the same event twice. Make processing idempotent, for example by recording the orderId and skipping work you've already done.
  • Handle partial batch failures. With batchSize: 5, one failing message would normally cause the whole batch to be retried. Enable reportBatchItemFailures on the event source and return the IDs of the failed messages so only those are retried.
  • Watch the DLQ. A dead-letter queue that nobody checks is just a place for problems to hide. Add a CloudWatch alarm on its message count.
  • Treat event shapes as contracts. Once other teams consume OrderPlaced, changing its fields can break them. Add fields rather than renaming or removing them, and version the detail-type when you must make breaking changes.
  • Clean up. When you're done experimenting, run cdk destroy so you don't leave resources running.

When this pattern fits, and when it doesn't

Events are a good fit when work can happen asynchronously: sending confirmation emails, updating inventory, generating reports, or notifying other systems. They are a poor fit when the caller needs an immediate answer, such as validating a login. Many real systems mix both: a synchronous API for the user-facing request, and events for everything that can happen afterwards.

Where to go next

  • Add more rules to the same bus so one event triggers several independent reactions.
  • Try Step Functions as a rule target for multi-step workflows.
  • Explore EventBridge Pipes and the Schema Registry to connect sources and document your event formats.
    Start with one event and one consumer, get comfortable with retries and dead-letter queues, and grow the design only as the application needs it.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article