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

  1. SNS or EventBridge: the honest comparison
  2. Buses: default, custom and partner
  3. Anatomy of an event
  4. MercadoFresco's event catalogue
  5. Rules and event patterns
  6. Targets and input transformation
  7. Retries, maximum event age and DLQ per target
  8. Events from AWS services
  9. Scheduled rules and EventBridge Scheduler
  10. Schema registry and code bindings
  11. Event archive and replay
  12. EventBridge Pipes and the mercadofresco-carritos Streams
  13. Event-driven architecture: contracts and versioning
  14. Observability and cost
  15. Common mistakes and tips
  16. Exercises
  17. 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 $PROFILE

MercadoFresco 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 response

put_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.

{
  "source": ["mercadofresco.tienda"],
  "detail-type": ["PedidoConfirmado"]
}

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}
  }]' $PROFILE

Testing 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" $PROFILE

EventBridge 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"}
  }' $PROFILE

The --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 $PROFILE

From 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."}]}' $PROFILE

And 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"]
  }' $PROFILE

FilterArns 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-name in 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 the REMOVEs 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"}}' $PROFILE

The 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 $PROFILE

A 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:

{ "source": ["mercadofresco.tienda"], "detail-type": ["PedidoConfirmado"] }

(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.

© Copyright 2026. All rights reserved