The fan-out from 07-02 works, but it already shows a ceiling. mercadofresco-pedido-confirmado
carries a single kind of fact; for PedidoCancelado, StockBajo and RepartoAsignado you would need
one topic for each, each with its own subscriptions, its queue policies and its filters. Four topics
today, twelve next year, and a map of subscriptions nobody knows how to draw in full. There are also
facts that matter and that the shop does not publish —an instance of asg-mercadofresco-tienda
changing state, Trusted Advisor spotting an idle resource, a deployment failing— and there is an open
wound: when the analytics queue was misconfigured, the events from those hours were lost for
ever.
Amazon EventBridge is AWS's serverless event bus. Instead of one channel per kind of fact, there is one bus where everything is published, and rules that decide where each event goes by looking at its full content. The sender knows nobody; the receiver does not depend on the sender; what joins them is a JSON pattern that can be changed without deploying code. And as a bonus, EventBridge receives events from over 200 AWS services out of the box and can archive and replay all of it.
In this lesson Luis creates bus-mercadofresco, designs the shop's business event catalogue, writes
patterns of growing complexity, connects an API destination towards the warehouse ERP, schedules the
nightly load into Redshift with cron, and finally puts to use the mercadofresco-carritos Streams
that were introduced and left unused in 06-02.
Cost warning. EventBridge charges 1.00 USD per million custom events published; events from AWS services on the default bus are free. The archive costs per GB stored and replay is charged as a new publication: replaying a month of events goes through the till again. Pipes and Scheduler have their own pricing. At the end you have the cleanup. All data is fictitious.
Contents
- SNS or EventBridge: the honest comparison
- Buses: default, custom and partner
- Anatomy of an event
- MercadoFresco's event catalogue
- Rules and event patterns
- Targets and input transformation
- Retries, maximum event age and DLQ per target
- Events from AWS services
- Scheduled rules and EventBridge Scheduler
- Schema registry and code bindings
- Event archive and replay
- EventBridge Pipes and the
mercadofresco-carritosStreams - Event-driven architecture: contracts and versioning
- Observability and cost
- Common mistakes and tips
- Exercises
- Conclusion
SNS or EventBridge: the honest comparison
Both deliver a message to several destinations. The difference is in how much they know about the content and in what they bring out of the box.
| Amazon SNS | Amazon EventBridge | |
|---|---|---|
| Unit | One topic per message type | One bus for many types |
| Filtering | Attributes or body, simple rules | Pattern over the whole event, nested, arrays |
| Targets per rule/subscription | 1 | Up to 5 |
| AWS sources | Only those that publish explicitly | 200+ services out of the box |
| SaaS sources | No | Partner buses (Datadog, Shopify, Zendesk…) |
| Schemas | No | Registry and automatic discovery |
| Archive and replay | No | Yes |
| Input transformation | No | Yes (input transformer) |
| Typical latency | Tens of ms | Hundreds of ms (~0.5 s) |
| Throughput | Very high (>100,000/s) | 10,000 publications/s by default (can be raised) |
| Cost per million | 0.50 USD to publish + deliveries | 1.00 USD to publish, deliveries included |
| Email, SMS, push | Yes | No |
The practical criterion. Use SNS when the pattern is pure fan-out of one fact to many destinations with simple filters, when minimum latency matters, when the volume is very high, or when the destination is a person (email, SMS). Use EventBridge when there are several event types over the same channel, when routing depends on the content, when you want to react to AWS or SaaS events, when you need archive and replay, or when the destination is an external API.
MercadoFresco ends up using both, and that is not a contradiction: bus-mercadofresco for the
business events and for everything coming from AWS, and SNS for the high-volume fan-out of
mercadofresco-pedido-confirmado —which already works and has lower latency— and for the notices to
people from alertas-mercadofresco. In fact, an SNS topic can be the target of an EventBridge
rule, which lets you combine them without duplicating anything.
Buses: default, custom and partner
An event bus is a named channel. There are three classes:
- Default bus (
default). It exists in every region without creating it. This is where the AWS services' events from the account arrive automatically: EC2 state changes, Trusted Advisor results, ECS task transitions, deployment failures. It also accepts your own events, but mixing them with the AWS noise complicates the rules and the archive. - Custom bus. The one your application creates for its business events. It allows its own access policies, its own archive and its own limits.
- Partner bus (partner event bus). Created by an associated SaaS to send you its events without you building webhooks: the payment gateway, the cloud ERP or the support tool.
export PROFILE="--profile mercadofresco-dev --region eu-west-1"
aws events create-event-bus --name bus-mercadofresco \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=integracion Key=Propietario,Value=marta \
Key=CentroCoste,Value=tecnologia $PROFILEMercadoFresco separates things clearly: bus-mercadofresco for the events the application publishes,
and the default bus to react to what AWS does.
Anatomy of an event
Every EventBridge event has the same envelope. The fields you control are four; the rest are set by the service.
{
"version": "0",
"id": "3f8a1c22-9e7d-4b10-a5f1-6c2b0e4d7a19",
"detail-type": "PedidoConfirmado",
"source": "mercadofresco.tienda",
"account": "111122223333",
"time": "2026-08-02T18:41:07Z",
"region": "eu-west-1",
"resources": ["arn:aws:rds:eu-west-1:111122223333:cluster:aurora-mercadofresco-pedidos"],
"detail": {
"version": 1,
"pedido_id": "PED-2026-084417",
"cliente_id": "CLI-30912",
"importe_eur": 48.20,
"franja_entrega": "24h",
"provincia": "Barcelona",
"lineas": [
{ "sku": "FRUT-FRES-011", "unidades": 2, "precio_eur": 3.10 },
{ "sku": "PESC-FRES-002", "unidades": 1, "precio_eur": 12.40 }
],
"confirmado_en": "2026-08-02T18:41:07Z"
}
}| Field | Who sets it | What it is for |
|---|---|---|
source |
You | Namespace of the sender. Convention: <company>.<domain> |
detail-type |
You | What has happened. It is the field most used for routing |
detail |
You | The payload, free-form JSON |
resources |
You | ARNs of the resources involved; allows filtering by resource |
id, time, region, account |
EventBridge | Metadata; time is used for archive and replay |
Two warnings about detail. First: the whole event cannot exceed 256 KB, the same as an SQS
message; if the detail is large, store it in S3 and send the reference. Second: detail is a contract
with third parties, not an internal structure. We will come back to it at the end of the lesson.
MercadoFresco's event catalogue
Before writing a single rule, Marta and Luis write the catalogue. It is a one-page document, and it is the most profitable piece of governance in the whole module.
source |
detail-type |
When it is emitted | Key detail fields |
|---|---|---|---|
mercadofresco.tienda |
PedidoConfirmado |
Charge succeeded and order written to Aurora | pedido_id, cliente_id, importe_eur, franja_entrega, provincia, lineas |
mercadofresco.tienda |
PedidoCancelado |
The customer or the system cancels an order | pedido_id, motivo, importe_devuelto_eur |
mercadofresco.almacen |
StockBajo |
A SKU drops below the restocking threshold | sku, unidades_restantes, umbral, proveedor_id |
mercadofresco.reparto |
RepartoAsignado |
A driver and a slot are assigned | pedido_id, repartidor_id, franja, eta |
Three conventions that avoid a lot of pain. The detail-type is in the past tense and describes a
fact, not an order: PedidoConfirmado, not ConfirmarPedido; if it describes an order, you are back
to the coupling you wanted to avoid. The source identifies the emitting domain, not the team or
the technical service, so that it survives reorganisations. And the detail always carries
version, which is the only thing that will let the contract evolve without breaking consumers.
Publishing is a single call:
import json, boto3
from datetime import datetime, timezone
eb = boto3.client("events", region_name="eu-west-1")
def publish_order_confirmed(order):
response = eb.put_events(Entries=[{
"EventBusName": "bus-mercadofresco",
"Source": "mercadofresco.tienda",
"DetailType": "PedidoConfirmado",
"Time": datetime.now(timezone.utc),
"Resources": ["arn:aws:rds:eu-west-1:111122223333:cluster:aurora-mercadofresco-pedidos"],
"Detail": json.dumps({"version": 1, **order}, ensure_ascii=False),
}])
# put_events returns 200 even if an entry fails: you have to check FailedEntryCount.
if response["FailedEntryCount"]:
for e in response["Entries"]:
if "ErrorCode" in e:
log.error("event not published",
extra={"code": e["ErrorCode"], "msg": e["ErrorMessage"]})
return responseput_events accepts up to 10 entries per call and has the same trap as send_message_batch from
07-01: 200 with partial failures inside. If you do not check FailedEntryCount, you lose events
silently.
Rules and event patterns
A rule has a pattern and up to five targets. An event pattern is a JSON with the same shape as the event, where each value is a list of alternatives. The event matches if every field in the pattern coincides.
The simplest pattern: all confirmed orders.
From there the language grows. These are the operators, applied to the order's detail:
| Operator | Pattern | What it selects |
|---|---|---|
| Exact match | {"detail":{"franja_entrega":["24h"]}} |
The urgent slot |
| List (OR) | {"detail":{"provincia":["Barcelona","Girona"]}} |
Either of the two |
prefix |
{"detail-type":[{"prefix":"Pedido"}]} |
PedidoConfirmado and PedidoCancelado |
suffix |
{"detail":{"lineas":{"sku":[{"suffix":"-BIO"}]}}} |
Some organic line |
anything-but |
{"detail":{"provincia":[{"anything-but":["Baleares","Canarias"]}]}} |
Everything but the islands |
numeric |
{"detail":{"importe_eur":[{"numeric":[">=",150]}]}} |
Large orders |
| Range | {"detail":{"importe_eur":[{"numeric":[">",50,"<=",200]}]}} |
Between 50 and 200 € |
exists |
{"detail":{"cupon":[{"exists":true}]}} |
Only with a coupon |
cidr |
{"detail":{"ip":[{"cidr":"10.0.0.0/16"}]}} |
Internal origin |
equals-ignore-case |
{"detail":{"provincia":[{"equals-ignore-case":"barcelona"}]}} |
Regardless of case |
$or |
See below | OR between different fields |
The composition rules are the same as in SNS and they are still the number one source of patterns that do not match: within a field the list is an OR; between different fields it is an AND. This pattern demands both things at once:
{
"source": ["mercadofresco.tienda"],
"detail-type": ["PedidoConfirmado"],
"detail": {
"franja_entrega": ["24h"],
"importe_eur": [{ "numeric": [">=", 60] }]
}
}And this one selects urgent or large, which is different:
{
"source": ["mercadofresco.tienda"],
"detail-type": ["PedidoConfirmado"],
"$or": [
{ "detail": { "franja_entrega": ["24h"] } },
{ "detail": { "importe_eur": [{ "numeric": [">=", 60] }] } }
]
}Arrays have a semantics of their own that has to be understood. The pattern
{"detail":{"lineas":{"sku":[{"prefix":"PESC-"}]}}} matches if any of the lines starts with
PESC-. There is no way to require that all of them do: EventBridge evaluates arrays with
existential semantics. If you need "all", the filter goes in the target, not in the rule.
Creating the rule and attaching targets to it:
aws events put-rule --name regla-mf-pedido-confirmado-almacen \
--event-bus-name bus-mercadofresco --state ENABLED \
--description "Routes confirmed orders towards the warehouse queue" \
--event-pattern '{"source":["mercadofresco.tienda"],"detail-type":["PedidoConfirmado"]}' $PROFILE
aws events put-targets --rule regla-mf-pedido-confirmado-almacen \
--event-bus-name bus-mercadofresco \
--targets '[{
"Id": "cola-almacen",
"Arn": "arn:aws:sqs:eu-west-1:111122223333:cola-mercadofresco-almacen",
"DeadLetterConfig": {"Arn":"arn:aws:sqs:eu-west-1:111122223333:mercadofresco-eventbridge-fallidas"},
"RetryPolicy": {"MaximumRetryAttempts": 8, "MaximumEventAgeInSeconds": 3600}
}]' $PROFILETesting a pattern without publishing anything is possible and always worth doing:
aws events test-event-pattern \
--event-pattern file://patron.json \
--event file://evento-ejemplo.json $PROFILE
# → { "Result": true }Targets and input transformation
A rule accepts up to five targets, of more than 20 types. The ones MercadoFresco uses:
| Target | Use | Note |
|---|---|---|
| SQS queue | Durable asynchronous work | The most common; allows MessageGroupId on FIFO |
| Lambda function | Immediate reaction | EventBridge manages the lambda:InvokeFunction permission |
| SNS topic | Reuse the fan-out already built | Joins the two worlds |
| State machine | Start the order process | We will see it in 07-04 |
| API destination | Call the partner's ERP over HTTPS | With managed authentication and backpressure |
| Another bus / another account | Federate environments | Requires a bus policy on the destination |
| Firehose, Kinesis, Log group | Dumping and auditing | To mercadofresco-registros-web |
The API destination deserves attention because it solves a real problem. Calling an external API from a Lambda forces you to manage credentials, retries and rate limits by hand. With an API destination EventBridge does it: it keeps the credentials in a connection (which in turn deposits them in Secrets Manager), adds the authentication header, and limits the invocations per second.
aws events create-connection --name conexion-erp-almacen \
--authorization-type API_KEY \
--auth-parameters '{"ApiKeyAuthParameters":{"ApiKeyName":"x-api-key","ApiKeyValue":"FICTICIA-123"}}' \
$PROFILE
aws events create-api-destination --name destino-api-erp-almacen \
--connection-arn arn:aws:events:eu-west-1:111122223333:connection/conexion-erp-almacen/abc123 \
--invocation-endpoint https://erp.socio.example/v1/pedidos \
--http-method POST \
--invocation-rate-limit-per-second 12 $PROFILE--invocation-rate-limit-per-second 12 is exactly the limit the ERP can take. EventBridge does not
exceed it even if 900 events arrive at once, and the rest wait with retries.
Input transformation (input transformer) avoids the coupling between the event format and what the target expects. Without it, the ERP would have to understand EventBridge's full envelope and the exact name of our fields, and any change in the event would break the partner.
{
"InputPathsMap": {
"order": "$.detail.pedido_id",
"customer": "$.detail.cliente_id",
"slot": "$.detail.franja_entrega",
"moment": "$.time"
},
"InputTemplate": "{\"orderRef\":\"<order>\",\"customerRef\":\"<customer>\",\"deliverySlot\":\"<slot>\",\"createdAt\":\"<moment>\",\"channel\":\"web\"}"
}InputPathsMap extracts values with JSONPath and gives them an alias; InputTemplate composes the
payload the target receives. The result is that the ERP receives its own vocabulary (orderRef,
customerRef) without MercadoFresco having to rename anything in its event. The translation lives
in the rule, which is the cheap place to change it.
Retries, maximum event age and DLQ per target
If a target fails, EventBridge retries with exponential backoff for 24 hours and up to 185 attempts by default. Both limits are adjusted per target, and the first one to be reached stops the attempts:
MaximumRetryAttempts: maximum number of retries (0–185).MaximumEventAgeInSeconds: maximum age of the event (60–86,400 s).
MaximumEventAgeInSeconds is the parameter that is forgotten most and the one that matters most for
the business. A delivery notice arriving 20 hours late is worth nothing: it is preferable for it to
fall into the DLQ within the hour and for somebody to look at it, rather than arriving when the
delivery has already happened. Marta sets it to 3,600 s for everything related to orders.
The DLQ per target (DeadLetterConfig) collects what could not be delivered. It is an ordinary SQS
queue, and the message that arrives includes attributes with the reason: RULE_ARN, TARGET_ARN,
ERROR_CODE, ERROR_MESSAGE and EXHAUSTED_RETRY_CONDITION. That last field is pure gold for
diagnosis: it says whether the retries were exhausted or the maximum age expired.
Do not confuse the three DLQs that now coexist in MercadoFresco:
| DLQ | Collects | Typical cause |
|---|---|---|
| Of the SQS queue | What the consumer could not process | Invalid data, dependency down |
| Of the SNS subscription | What SNS could not deliver | Permissions, endpoint down |
| Of the EventBridge target | What EventBridge could not deliver | Permissions, saturated target, expired age |
Events from AWS services
This is the capability SNS does not have: more than 200 AWS services publish to the default bus
without you configuring anything. All you have to do is write the rule.
An instance from the shop's group shutting down or moving to a degraded state:
{
"source": ["aws.ec2"],
"detail-type": ["EC2 Instance State-change Notification"],
"detail": { "state": ["stopped", "terminated", "stopping"] }
}A Trusted Advisor finding (05-05), which until now had to be checked by hand:
{
"source": ["aws.trustedadvisor"],
"detail-type": ["Trusted Advisor Check Item Refresh Notification"],
"detail": { "status": ["WARN", "ERROR"] }
}An ECS task that dies (module 10) or a CodeDeploy deployment that fails (08-03):
{
"source": ["aws.ecs", "aws.codedeploy"],
"detail-type": [
"ECS Task State Change",
"CodeDeploy Deployment State-change Notification"
],
"detail": { "state": ["STOPPED"], "status": ["FAILURE"] }
}Beware of that last one: when combining two sources in one rule, the detail pattern applies to both
and may match neither. One rule per source is cleaner, even if it looks repetitive: the patterns
stay readable and each one can have its own targets and its own DLQ.
MercadoFresco creates regla-mf-infra-incidentes with alertas-mercadofresco as its target, and the
input transformation turns the raw event into a sentence Marta can read:
{
"InputPathsMap": { "resource": "$.detail.instance-id", "state": "$.detail.state" },
"InputTemplate": "\"Instance <resource> has moved to state <state> in eu-west-1.\""
}This is a good moment to point out the change of mindset: operations moves from checking to reacting. Instead of somebody reviewing Trusted Advisor every Monday, an event opens a ticket when there is something to look at.
Scheduled rules and EventBridge Scheduler
EventBridge also fires on time. There are two mechanisms and it is worth knowing which to use.
The classic scheduled rules live on the default bus and use schedule-expression:
aws events put-rule --name regla-mf-limpiar-carritos \
--schedule-expression "cron(0 3 * * ? *)" --state ENABLED \
--description "Daily cleanup of expired baskets at 03:00 UTC" $PROFILEEventBridge Scheduler is the dedicated service, newer and preferable for almost everything: it supports time zones with daylight saving, dispersion windows (flexible time windows) so as not to launch a thousand tasks in the same second, one-off schedules, and more than 270 targets with the same retries and DLQ as the rules.
aws scheduler create-schedule-group --name grupo-mf-programado $PROFILE
aws scheduler create-schedule --name programador-mf-carga-nocturna \
--group-name grupo-mf-programado \
--schedule-expression "cron(30 2 * * ? *)" \
--schedule-expression-timezone "Europe/Madrid" \
--flexible-time-window '{"Mode":"FLEXIBLE","MaximumWindowInMinutes":15}' \
--target '{
"Arn": "arn:aws:lambda:eu-west-1:111122223333:function:mercadofresco-carga-redshift",
"RoleArn": "arn:aws:iam::111122223333:role/rol-mf-scheduler",
"Input": "{\"origen\":\"aurora\",\"destino\":\"analitica.hechos_pedidos\",\"modo\":\"incremental\"}",
"RetryPolicy": {"MaximumRetryAttempts": 3, "MaximumEventAgeInSeconds": 3600},
"DeadLetterConfig": {"Arn":"arn:aws:sqs:eu-west-1:111122223333:mercadofresco-eventbridge-fallidas"}
}' $PROFILEThe --schedule-expression-timezone "Europe/Madrid" is the main reason to prefer Scheduler: with a
classic rule in UTC, the nightly load would run at 04:30 in winter and 03:30 in summer, out of line
with the accounting close. And MaximumWindowInMinutes: 15 spreads the start so as not to create a
peak of connections against aurora-mf-lector-2.
| Scheduled rule | EventBridge Scheduler | |
|---|---|---|
| Time zone and daylight saving | No (UTC only) | Yes |
| Flexible window | No | Yes |
| One-off schedule | No | Yes (at(...)) |
| Limit per account | 300 rules per bus | 1 million schedules |
| Targets | ~20 types | 270+ |
| Cost | Free | 1.00 USD/million invocations |
MercadoFresco's two nightly tasks end up like this: programador-mf-carga-nocturna at 02:30 mainland
time loads the day's orders into analitica.hechos_pedidos in wg-mercadofresco-analitica, and
programador-mf-limpiar-carritos at 03:00 walks through and consolidates what the TTL of
mercadofresco-carritos has already been expiring. Careful: AWS cron expressions have six fields
(minute, hour, day of month, month, day of week, year) and do not accept * in day of month and day
of week at the same time —hence the ?—.
Schema registry and code bindings
The schema registry stores the structure of your events in OpenAPI/JSON Schema format. With discovery enabled on a bus, EventBridge infers the schema of what passes through it and versions it automatically.
aws schemas create-discoverer \
--source-arn arn:aws:events:eu-west-1:111122223333:event-bus/bus-mercadofresco $PROFILE
aws schemas list-schemas --registry-name discovered-schemas $PROFILEFrom a schema you generate code bindings for Python, Java or TypeScript: typed classes that
represent the event, so the consumer stops writing event["detail"]["pedido_id"] with its fingers
crossed. In Python the added value is smaller than in Java, but the registry is still useful for
another reason: it is the living documentation of the catalogue. When marketing asks what fields
PedidoConfirmado carries, the answer is a versioned schema, not a chat message. Discovery costs
about 0.10 USD per million events processed, so it is worth keeping it on only while the catalogue is
evolving.
Event archive and replay
This is the capability that would have saved the 07-02 incident. An archive keeps a copy of every event that passes through a bus —optionally filtered by pattern— for as long as you decide.
aws events create-archive --archive-name archivo-mercadofresco-eventos \
--event-source-arn arn:aws:events:eu-west-1:111122223333:event-bus/bus-mercadofresco \
--retention-days 90 \
--event-pattern '{"source":[{"prefix":"mercadofresco."}]}' $PROFILEAnd when something goes wrong, a time window is replayed towards the affected rules:
aws events start-replay --replay-name reproduccion-analitica-20260802 \
--event-source-arn arn:aws:events:eu-west-1:111122223333:archive/archivo-mercadofresco-eventos \
--event-start-time 2026-08-02T08:00:00Z \
--event-end-time 2026-08-02T14:00:00Z \
--destination '{
"Arn": "arn:aws:events:eu-west-1:111122223333:event-bus/bus-mercadofresco",
"FilterArns": ["arn:aws:events:eu-west-1:111122223333:rule/bus-mercadofresco/regla-mf-pedido-confirmado-analitica"]
}' $PROFILEFilterArns is essential and it is what separates a recovery from a disaster: without it, the replay
fires every rule on the bus, and you would charge cards again, send emails again and tell the
warehouse about orders from six hours ago. With it, only the analytics rule is reprocessed, which is
the one that missed the events.
Three more things to know before using it in earnest:
- Replayed events carry
replay-namein the envelope. A consumer can tell them apart and act accordingly —for instance, not notifying anybody again—. - Ordering is not guaranteed within the replay, and the events arrive far faster than they did originally. The consumer must be idempotent and keep up with the pace.
- Replaying costs the same as publishing. A month of events replayed is a month of events billed again, plus the archive storage (about 0.10 USD per GB per month).
The archive is also useful for something less dramatic and very handy: populating a test environment with real traffic, replaying a Friday afternoon against a development bus.
EventBridge Pipes and the mercadofresco-carritos Streams
In 06-02 we enabled DynamoDB Streams on mercadofresco-carritos and left it there, introduced and
unexploited. EventBridge Pipes is the missing piece: a point-to-point pipeline between a source
that is polled and a target, with two optional steps in between.
flowchart LR
ORIG[(DynamoDB Streams<br/>mercadofresco-carritos)] --> FIL[Filtering<br/>only REMOVE by TTL]
FIL --> ENR[Enrichment<br/>Lambda: adds customer data]
ENR --> DEST{{bus-mercadofresco<br/>CarritoAbandonado}}
DEST --> R1[rule: notify marketing]
DEST --> R2[rule: analytics queue]
style FIL fill:#fff3cd
style ENR fill:#cfe2ff
The Pipes sources are the ones that have to be polled: DynamoDB Streams, Kinesis, SQS, Amazon MQ and Kafka. The targets are any of EventBridge's. And the two intermediate steps are the reason it exists:
- Filtering: discards what is not of interest before paying to process it. The basket stream
generates one record per
UpdateItem—hundreds of thousands a day— but the only ones of interest are theREMOVEs caused by the TTL, which are the genuinely abandoned baskets. - Enrichment: calls a Lambda, an API or a state machine to complete the record before delivering
it. The stream carries the
CLIENTE#<id>but not the email or the name; the enrichment looks them up in Aurora.
aws pipes create-pipe --name pipe-mf-carritos-abandonados \
--role-arn arn:aws:iam::111122223333:role/rol-mf-pipes \
--source arn:aws:dynamodb:eu-west-1:111122223333:table/mercadofresco-carritos/stream/2026-07-01T00:00:00.000 \
--source-parameters '{
"DynamoDBStreamParameters": {"StartingPosition":"LATEST","BatchSize":50,
"MaximumBatchingWindowInSeconds":30},
"FilterCriteria": {"Filters":[{"Pattern":"{\"eventName\":[\"REMOVE\"],\"userIdentity\":{\"principalId\":[\"dynamodb.amazonaws.com\"]}}"}]}
}' \
--enrichment arn:aws:lambda:eu-west-1:111122223333:function:mercadofresco-enriquecer-carrito \
--target arn:aws:events:eu-west-1:111122223333:event-bus/bus-mercadofresco \
--target-parameters '{"EventBridgeEventBusParameters":{"Source":"mercadofresco.carritos",
"DetailType":"CarritoAbandonado"}}' $PROFILEThe filter has an exquisite detail: userIdentity.principalId = dynamodb.amazonaws.com distinguishes
the deletions made by the TTL from those the application makes when confirming an order. Without
that condition, marketing would send "you forgot something" emails to customers who have just bought.
It is a perfect example of why filtering has to sit close to the source and understand the domain.
This closes a thread that had been open since module 6: the 55 GB of zombie baskets that the TTL started cleaning up now also generate a business event you can use.
Event-driven architecture: contracts and versioning
Events decouple, but not for free. There are three truths worth accepting early.
An event is a public API. As soon as you publish it on a shared bus, you do not know who consumes it: that is the point. And then you cannot change it as you please. Removing a field, renaming it or changing its type breaks consumers you do not even know exist, and you find out in production.
The rules of compatible evolution are short: you can add optional fields; you cannot remove
or rename a field, change its type, change the meaning of an existing value, or tighten a constraint.
When an incompatible change is needed, a new version is published in parallel —"version": 2 in the
detail, or a new detail-type PedidoConfirmadoV2— and both are kept until the consumers migrate.
That is why version goes in the detail from the very first event: without it, the migration has
nowhere to start.
Coupling does not disappear, it moves. Before, the shop depended on the ERP; now everybody depends
on the event format. It is a very favourable change —the contract is explicit, versionable and
verifiable with test-event-pattern— but it is still a dependency. The discipline that keeps it
healthy is the catalogue's: nothing is published on bus-mercadofresco without being in the table, and
nothing is changed without bumping the version.
One last warning about design: events describe facts, commands order actions. If your event is
called EnviarCorreoConfirmacion, you have put an order on a bus, and the sender knows again what has
to happen next. The correct event is PedidoConfirmado; that this implies an email is the consumer's
decision.
Observability and cost
| Metric | What it indicates | Action |
|---|---|---|
TriggeredRules |
Rules that matched | If it is 0, the pattern does not match |
MatchedEvents |
Events that matched some rule | Compare with what was published |
FailedInvocations |
The target rejected the invocation | Alarm: almost always permissions |
InvocationsFailedToBeSentToDlq |
It could not even write to the DLQ | Critical alarm |
ThrottledRules |
The invocation limit was exceeded | Ask for a quota increase |
PutEventsFailedEntriesCount |
Entries rejected on publishing | Review the publisher |
The most useful combination for debugging is MatchedEvents = 0 with non-zero publications: it means
no pattern matches, and it is almost always a misspelt source or a detail-type with one capital
letter of difference —patterns are case-sensitive—.
EventBridge also integrates with CloudWatch Logs as a target: a rule with a broad pattern and a log group as its target lets you see everything that goes through the bus during an investigation. It is expensive to leave on permanently, but it is the quickest tool when something does not turn up.
| Item | Price | MercadoFresco |
|---|---|---|
| Custom events published | 1.00 USD/million | 480,000/month → 0.48 USD |
| Events from AWS services | Free | ~90,000/month → 0 USD |
| Rules and targets | Free | — |
| Archive (storage) | ~0.10 USD/GB-month | 3.4 GB × 90 days → 0.34 USD |
| Replay | 1.00 USD/million | Only during incidents |
| Schema discovery | ~0.10 USD/million | 0.05 USD |
| Pipes | ~0.40 USD/million requests | 210,000 after filtering → 0.08 USD |
| Scheduler | 1.00 USD/million | 60/month → 0 USD |
| Total | ~0.95 USD/month |
Unfiltered events are what can blow up the bill: if the basket pipe did not filter out the MODIFYs,
it would process 5.4 million records a month instead of 210,000. Filtering at the source is not just
design hygiene; it is the main line item.
aws events remove-targets --rule regla-mf-pedido-confirmado-almacen \
--event-bus-name bus-mercadofresco --ids cola-almacen $PROFILE
aws events delete-rule --name regla-mf-pedido-confirmado-almacen \
--event-bus-name bus-mercadofresco $PROFILE
aws pipes delete-pipe --name pipe-mf-carritos-abandonados $PROFILE
aws scheduler delete-schedule --name programador-mf-carga-nocturna \
--group-name grupo-mf-programado $PROFILE
aws events delete-archive --archive-name archivo-mercadofresco-eventos $PROFILE
aws events delete-event-bus --name bus-mercadofresco $PROFILEA bus cannot be deleted if it has rules with targets: you have to remove targets, then rules, then the
bus. It is the usual cause of ResourceInUseException in cleanup scripts.
Common Mistakes and Tips
A pattern that does not match because of capitals or a misspelt source. MatchedEvents at 0 with
no error at all. Use test-event-pattern before deploying; it saves you hours. And do not confuse OR
and AND: within a field, list = OR; between fields, AND; for an OR between fields, $or.
Expecting a pattern over an array to require "all". EventBridge evaluates arrays with existential semantics: it matches if any of the elements complies.
Not checking FailedEntryCount in put_events. It returns 200 with partial failures, just like
send_message_batch. Events lost silently.
Publishing business events on the default bus. They get mixed with the AWS noise, the rules
become fragile and the archive fills with events nobody wants. Use a bus of your own.
Leaving the maximum event age at 24 hours. A delivery notice arriving 20 hours late is worse than
a visible failure. Set MaximumEventAgeInSeconds to the business value.
Replaying without FilterArns. It fires every rule on the bus and repeats effects that already
happened. It is the most expensive mistake in this lesson.
Forgetting the target's DLQ. Without it, what is not delivered disappears without a trace.
Putting commands on the bus. If the detail-type is a verb in the imperative, you have
reintroduced the coupling you wanted to eliminate.
Changing the detail without bumping version. You break consumers you do not know exist and you
find out in production.
Tip: one rule per intention, not one rule with five heterogeneous targets. Separate rules have their own DLQ, their own maximum age and their own metric, and they can be disabled one at a time during an incident.
Tip: write the catalogue before the code. The table of source / detail-type / fields is ten
minutes of work that prevents months of incoherent events.
Tip: enable the archive from day one. It costs pennies and it is the only way to recover what a misconfigured consumer missed.
Exercises
Exercise 1: routing the whole catalogue
Design the rules for bus-mercadofresco for these four requirements: (1) every PedidoConfirmado
goes to cola-mercadofresco-almacen; (2) PedidoConfirmado events in the 24h slot or with an
amount ≥ 100 € also go to the ERP's API destination with priority; (3) StockBajo events for fresh
products (SKUs starting with FRUT- or PESC-) with fewer than 5 units notify
alertas-mercadofresco; (4) any event from mercadofresco.* is archived for 90 days.
Write the JSON patterns, say how many rules you create and why, and what input transformation you would use in requirement 3.
Exercise 2: the consumer that missed an afternoon
On Tuesday at 09:00 Luis deploys a version of the analytics Lambda that throws an exception on
start-up. Nobody notices until 15:00. The rule regla-mf-pedido-confirmado-analitica delivers
directly to that Lambda, with no queue. The archive has been active for three months.
Answer: (a) what has happened to the events of those six hours according to the default retry configuration and according to a maximum age of 3,600 s; (b) how you recover the data, with the exact command; (c) what precaution you take before launching it; (d) which two architectural changes stop it happening again; (e) what alarm would have warned at 09:05.
Exercise 3: SNS, EventBridge or Pipes
Decide the service and justify it in two sentences: (a) three teams want the confirmed orders, with
simple filters and the lowest possible latency; (b) the partner's ERP has to be called over HTTPS with
an API key and at most 12 requests per second; (c) the on-call engineer has to be warned by SMS; (d)
the Redshift load has to be launched every day at 02:30 mainland time; (e) you have to react to the
TTL deletions in mercadofresco-carritos enriching them with data from Aurora; (f) you have to react
to an EC2 instance moving to terminated.
Solutions
Solution 1
Four rules plus an archive. They are kept separate because each one has its own target, maximum age and DLQ, and because a rule with mixed patterns becomes unreadable.
(1) regla-mf-pedido-confirmado-almacen:
(2) regla-mf-pedido-prioritario-erp. The OR between different fields forces $or:
{
"source": ["mercadofresco.tienda"],
"detail-type": ["PedidoConfirmado"],
"$or": [
{ "detail": { "franja_entrega": ["24h"] } },
{ "detail": { "importe_eur": [{ "numeric": [">=", 100] }] } }
]
}Target destino-api-erp-almacen with a low MaximumEventAgeInSeconds (900 s: an urgent order that
does not arrive within 15 minutes has to be handled by hand) and DLQ mercadofresco-eventbridge-fallidas.
(3) regla-mf-stock-bajo-fresco. Here it is an AND between fields: SKU prefix and units.
{
"source": ["mercadofresco.almacen"],
"detail-type": ["StockBajo"],
"detail": {
"sku": [{ "prefix": "FRUT-" }, { "prefix": "PESC-" }],
"unidades_restantes": [{ "numeric": ["<", 5] }]
}
}The two prefix entries inside the sku list are an OR, which is exactly what is asked for. Input
transformation, because the target is an SNS topic that ends up in a person's email:
{
"InputPathsMap": { "sku": "$.detail.sku", "left": "$.detail.unidades_restantes" },
"InputTemplate": "\"Critical stock: <left> units of <sku> left. Restock today.\""
}(4) It is not a rule, it is an archive with the pattern {"source":[{"prefix":"mercadofresco."}]}
and --retention-days 90. The prefix avoids archiving AWS events that add nothing and do take space.
Solution 2
(a) With the default values (185 retries, 24 hours), the 09:00 events would still be retrying at
15:00, and once the Lambda was fixed some of them would be delivered on their own, though hours
late and out of order. With MaximumEventAgeInSeconds=3600, each event is discarded an hour after it
was published: at 15:00 everything before 14:00 is in the target's DLQ and the rest is still retrying.
The second configuration is preferable: it turns a silent degradation into a visible pile of messages
in the DLQ.
(b) First you fix the Lambda and check it with a test event. Then:
aws events start-replay --replay-name reproduccion-analitica-20260804 \
--event-source-arn arn:aws:events:eu-west-1:111122223333:archive/archivo-mercadofresco-eventos \
--event-start-time 2026-08-04T07:00:00Z --event-end-time 2026-08-04T13:00:00Z \
--destination '{"Arn":"arn:aws:events:eu-west-1:111122223333:event-bus/bus-mercadofresco",
"FilterArns":["arn:aws:events:eu-west-1:111122223333:rule/bus-mercadofresco/regla-mf-pedido-confirmado-analitica"]}'The times go in UTC: 09:00–15:00 mainland time in August is 07:00–13:00 UTC. Getting this wrong is a classic.
(c) Three precautions. FilterArns is mandatory, or the replay would fire every rule and would
notify the warehouse again and bill orders from six hours ago all over again. Check that the
consumer is idempotent, because some of the events may have been delivered before the failure and
the replay will repeat them. And empty or review the target's DLQ first, to avoid double routes.
(d) First, put a queue between the rule and the Lambda: with SQS, six hours of failure leave 5,000 messages waiting and they are processed once the problem is fixed, with no replay at all; it is the same lesson from 07-02 applied to EventBridge. Second, a DLQ on the target plus an alarm, so the signal appears within minutes. As a third measure, a deployment with a health check that rolls back on its own, which is precisely the subject of module 8.
(e) An alarm on the rule's FailedInvocations, threshold > 0 over a 5-minute period, towards
alertas-mercadofresco. A complementary one: the Lambda function's Errors. Either of the two would
have warned at 09:05 instead of at 15:00.
Solution 3
(a) SNS. Pure fan-out, simple filters and a minimum-latency requirement: SNS delivers in tens of milliseconds against EventBridge's hundreds, and it is already built. Each team with its own queue.
(b) EventBridge with an API destination. It is exactly its use case: it manages the key in a
connection backed by Secrets Manager, applies invocation-rate-limit-per-second 12 and retries with a
DLQ, all without writing a line of code.
(c) SNS. It is the only one of the three that delivers SMS. On top of that, the CloudWatch alarm already publishes there natively.
(d) EventBridge Scheduler. It needs a time zone with daylight saving (Europe/Madrid), which
classic scheduled rules do not support, and the flexible window avoids the peak of connections against
the Aurora reader.
(e) EventBridge Pipes. It is the only one that consumes DynamoDB Streams with filtering before
billing and with an integrated enrichment step. Doing it with a Lambda subscribed to the stream would
force you to filter and enrich by hand, processing —and paying for— millions of unwanted MODIFYs.
(f) EventBridge, default bus. The EC2 events arrive on their own and are free; there is nothing
to publish. A rule with a pattern over aws.ec2 and alertas-mercadofresco as its target.
Conclusion
MercadoFresco no longer has one topic per kind of fact, but one bus where PedidoConfirmado,
PedidoCancelado, StockBajo and RepartoAsignado live together, and where rules with JSON patterns
decide where each one goes by looking at its content: the slot, the amount, the province, the SKU
prefix. The partner's ERP receives its own vocabulary thanks to the input transformation, without
anybody renaming anything in the event. The nightly tasks —the load into analitica.hechos_pedidos
and the basket consolidation— run with programador-mf-carga-nocturna in mainland time and with a
flexible window. Infrastructure incidents and Trusted Advisor findings arrive on their own, without
anybody having to go and look. And the mercadofresco-carritos Streams, introduced in 06-02 and
unused until today, finally feed a business event through pipe-mf-carritos-abandonados, with a
filter that tells the TTL deletion apart from the purchase deletion.
Two ideas carry the weight of the lesson. The first is that content-based routing changes who
decides: it is no longer the sender that hands out work, but a declarative rule that is modified
without deploying code. The second is that an event is a public API, with everything that implies:
you can add, you cannot remove, and without version in the detail no migration is possible. In
between sits archivo-mercadofresco-eventos, the safety net that turns "we have lost six hours of
data" into a one-command replay —always with FilterArns, or the cure will be worse than the
disease—.
But look at what is still unsolved. Everything we have built is choreography: each component reacts to what it sees and nobody has the full picture. It works beautifully for independent facts, and it breaks when the process has steps that depend on one another and you have to undo what has been done if something fails halfway. The MercadoFresco order is exactly that: charge, reserve stock, ask the warehouse to prepare it —which takes between two minutes and an hour and is confirmed by a person with a barcode scanner—, assign delivery and confirm to the customer. If the warehouse cannot prepare the box because the fish arrived in poor condition, the charge has to be refunded and the stock released, and with loose events nobody knows where the process was or what needs compensating.
In 07-04, "AWS Step Functions", we move from choreography to orchestration: a state machine,
mercadofresco-procesar-pedido, that knows the whole process, records which step it is on, retries
with exponential backoff, waits for a human to confirm through a task token, processes Friday's 900
confirmations in parallel with distributed Map, and —most importantly— knows how to undo what it
had already done when something goes wrong.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
