Series: Event-Driven Architecture on AWS · Part 2 of 3
- Queue Everything: How SQS-Based Architecture Improved Our API Response Times
- EventBridge Pipes and Scheduler: Deleting the Glue Lambdas
- EventBridge vs SNS: When to Use Which (and When to Use Both)
Contents
The Glue Lambda Problem
Look through any mature serverless estate and you'll find functions like these:
- A Lambda that reads a DynamoDB stream, drops irrelevant records and forwards the rest to SQS.
- A Lambda that polls a queue, calls an internal API to enrich each message, and posts it somewhere else.
- A "cron" Lambda triggered every five minutes that checks a table for things due to happen.
- A Lambda that exists only to call a third-party webhook with a transformed payload.
Each one needs code, tests, deploys, IAM, logging, alarms and runtime upgrades. None of them contain business logic. Two EventBridge features exist largely to delete them.
EventBridge Pipes: Point-to-Point Integration as Configuration
A pipe connects one source to one target, with optional filtering and enrichment in between:
Source ──► Filter ──► Enrichment (optional) ──► Target- Sources: SQS, Kinesis Data Streams, DynamoDB Streams, Amazon MSK and self-managed Kafka, Amazon MQ.
- Filtering: the same event-pattern syntax as EventBridge rules. Filtered-out events are dropped before you pay for enrichment or targets.
- Enrichment: Lambda, Step Functions (Express), API Gateway, or an API destination (any HTTP endpoint).
- Targets: a long list including SQS, SNS, Step Functions, Lambda, EventBridge buses, Kinesis, ECS tasks, API destinations and more.
- Input transformation to reshape the payload for the target.
Example: DynamoDB Stream → only cancelled subscriptions → event bus
# Simplified CloudFormation
SubscriptionCancelledPipe:
Type: AWS::Pipes::Pipe
Properties:
RoleArn: !GetAtt PipeRole.Arn
Source: !GetAtt SubscriptionsTable.StreamArn
SourceParameters:
DynamoDBStreamParameters:
StartingPosition: LATEST
BatchSize: 10
FilterCriteria:
Filters:
- Pattern: '{"eventName":["MODIFY"],"dynamodb":{"NewImage":{"status":{"S":["cancelled"]}}}}'
Target: !GetAtt DomainEventBus.Arn
TargetParameters:
EventBridgeEventBusParameters:
DetailType: SubscriptionCancelled
Source: com.company.subscriptions
InputTemplate: '{"subscriptionId": <$.dynamodb.NewImage.id.S>, "customerId": <$.dynamodb.NewImage.customerId.S>}'No function code. The table's change stream becomes a clean domain event on the bus, where other teams can subscribe with rules.
Where Pipes shine
- Change data capture to events. DynamoDB or Kinesis streams into domain events.
- Queue-to-workflow. SQS into Step Functions, with filtering, so each message starts a workflow.
- Enrich-then-forward. Add customer data from an internal API before delivering to a partner endpoint.
Watch out for
- Pipes are one-to-one. For fan-out, target an event bus or SNS topic and fan out from there.
- Error handling differs by source. Stream sources need a dead-letter configuration and a retry/bisect strategy; SQS sources rely on the queue's redrive policy. Set these explicitly.
- Enrichment is synchronous. A slow enrichment endpoint throttles the whole pipe.
EventBridge Scheduler: Cron Done Properly
EventBridge Scheduler is a separate service from the older "scheduled rules" on an event bus, and it's much more capable:
- One-time and recurring schedules (cron, rate, or a specific timestamp).
- Time zones and daylight saving handled correctly, which matters when your cut-off is "10pm UK time" all year round.
- Massive scale. Designed for very large numbers of schedules, so you can create one schedule per entity rather than one schedule that scans a table.
- Universal targets. Call almost any AWS API directly, not just Lambda.
- Flexible time windows to spread load, plus retry policies and dead-letter queues.
Example: per-customer reminder instead of a polling job
The old pattern: a Lambda runs every five minutes, queries for "customers whose delivery cut-off is in 24 hours", and sends reminders. It's wasteful, and easy to get wrong around daylight saving changes.
The Scheduler pattern: when a delivery is scheduled, create a one-time schedule for exactly 24 hours before the cut-off.
import { SchedulerClient, CreateScheduleCommand } from "@aws-sdk/client-scheduler";
const scheduler = new SchedulerClient({});
await scheduler.send(new CreateScheduleCommand({
Name: `cutoff-reminder-${deliveryId}`,
GroupName: "delivery-reminders",
ScheduleExpression: `at(${reminderTime})`, // e.g. at(2026-02-20T22:00:00)
ScheduleExpressionTimezone: "Europe/London",
FlexibleTimeWindow: { Mode: "OFF" },
ActionAfterCompletion: "DELETE",
Target: {
Arn: remindersQueueArn,
RoleArn: schedulerRoleArn,
Input: JSON.stringify({ deliveryId, customerId }),
DeadLetterConfig: { Arn: schedulerDlqArn },
},
}));When the delivery is skipped or rescheduled, delete or update the schedule. No polling, no table scans, and the DST edge case is AWS's problem.
Watch out for
- Keep schedules in sync with your source of truth. If an update to the delivery fails to update the schedule, you'll send a wrong reminder. Make schedule changes part of the same idempotent workflow.
- Name schedules deterministically (like
cutoff-reminder-{deliveryId}) so updates and deletes are simple and retries are idempotent. - Target a queue, not the worker. Delivering to SQS keeps retries and concurrency under your control.
Pipes, Scheduler, or Code?
| Need | Use |
|---|---|
| Move data from one stream or queue to one target, with filtering | Pipes |
| Enrich messages with an API call before delivery | Pipes with enrichment |
| Fan out one event to many consumers | Event bus rules (or SNS) |
| Run something at a specific time per entity | Scheduler (one-time schedules) |
| Run a platform job on a recurring cadence | Scheduler (recurring) |
| Real business logic, complex branching | Code (Lambda) or Step Functions |
The Takeaway
Code that contains no business logic is a liability. Before writing the next glue Lambda, check whether a pipe or a schedule expresses it as configuration. You'll delete code, remove runtime upgrades from your backlog, and make the integration visible in infrastructure definitions rather than buried in a handler.
