Skip to main content

06 - AWS EventBridge: Serverless Event Bus and Content-Based Routing

EventBridge routes by content: a pattern matcher, a bus with archive and replay, and what the documentation says on PutEvents, retries and the schema registry.

What do you think about this article?

At a Glance

EventBridge routes by content: a pattern matcher, a bus with archive and replay, and what the documentation says on PutEvents, retries and the schema registry.

A bus that decides based on content

You have an Orders service that publishes OrderPlaced. You want orders above 500 euros from premium customers to reach a dedicated team, any error event to end up in an analysis queue, and no test order to reach production. If the publisher decides on its own who gets what, every new rule is a change to the publisher. Amazon EventBridge moves the decision onto the bus: the publisher hands over the event, and the rules (each with an event pattern and one or more targets) choose the recipients by looking at the content of the JSON.

There is no AWS account here: we run the mechanism in miniature, a pattern matcher and a bus with rules, archive and replay, and then we look at what the official documentation says about the real service, consulted on 2026-11-12. A clarification that the rest of the article respects: today the EventBridge guide describes two types of bus, the Custom Event Bus, recommended for new applications, which declares delivery ordering, replay and retention of events, and the Custom Event Bus - Classic, the one with rules and targets that you write yourself, which remains available.

The model of this article (rules, patterns, targets, archive) is that of the Classic bus guide. If you have already read the comparison with SQS and SNS you know where the bus fits; for the underlying theory see the fundamentals of events.

The matcher: what “matches” means

A pattern has the same structure as the event it wants to select, and an event either matches or it does not: there are no intermediate scores. There are three rules to remember. A pattern is a confirmation field by field: all the named fields must match (AND), while the event fields that are not named are ignored.

An array of values is an OR: ["PREMIUM", "VIP"] means “one of these”. An object in place of the value is an operator. The program implements only a subset of the guide's operators (the rest, such as $or, wildcards or filters on IP addresses, is not there) and does so to show the logic, not to reproduce the real engine.

// Subset of the filter operators described in the EventBridge documentation:
// exact values (array = OR), prefix, suffix, anything-but, numeric, exists.
type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
type Obj = { [k: string]: Json };

function operatorMatches(op: Obj, v: Json | undefined): boolean {
  if ('exists' in op) return (v !== undefined) === op.exists;
  if (v === undefined) return false;
  if ('prefix' in op) return typeof v === 'string' && v.startsWith(op.prefix as string);
  if ('suffix' in op) return typeof v === 'string' && v.endsWith(op.suffix as string);
  if ('anything-but' in op) {
    const banned = ([] as Json[]).concat(op['anything-but']);
    return !banned.includes(v);
  }
  if ('numeric' in op) {
    if (typeof v !== 'number') return false;
    const c = op.numeric as (string | number)[];
    for (let i = 0; i < c.length; i += 2) {
      const n = c[i + 1] as number;
      const ok = { '<': v < n, '<=': v <= n, '>': v > n, '>=': v >= n, '=': v === n }[c[i] as string];
      if (!ok) return false;
    }
    return true;
  }
  throw new Error('unsupported operator: ' + Object.keys(op).join(','));
}

// All the pattern fields must match (AND); the event fields not named by the pattern are ignored.
export function matches(pattern: Obj, event: Obj): boolean {
  return Object.entries(pattern).every(([field, expected]) => {
    const v = event[field];
    if (Array.isArray(expected)) {
      // The array is an OR of exact values or of operators.
      return expected.some((a) => (a !== null && typeof a === 'object' && !Array.isArray(a)) ? operatorMatches(a, v) : a === v);
    }
    if (expected !== null && typeof expected === 'object') {
      return v !== null && typeof v === 'object' && !Array.isArray(v) && matches(expected as Obj, v as Obj);
    }
    throw new Error(`the pattern "${field}" must be an array or an object`);
  });
}

The matches function walks the pattern. If the expected value is an array, some tries each alternative: a plain value is compared with ===, an object goes to operatorMatches. If it is a nested object, for example detail, it descends recursively.

The exists operator is the only one that accepts an absent field; for all the others a missing field means no match, which is the reason why a pattern on detail.customerType does not select the orders of whoever does not send that field. Numbers are compared as numbers: { numeric: ['>', 500] } does not accept the string "750". The module is exercised by the next program.

A bus with rules, archive and replay

The program's bus does the three things the service describes: put receives the event, assigns it an id and a time and delivers it to the targets of the rules that match; every event also ends up in an archive; replay rereads the archive in an interval and delivers only to the indicated rules. The three rules are those of the opening example: VIP orders above 500 euros, errors from any service of the app, orders outside test and staging. The five calls to put are chosen to hit the edge cases: an order that passes one rule and not the other, one that passes them all and one that comes from a foreign application.

import { matches } from './pattern.ts';

type Obj = { [k: string]: any };
type Rule = { name: string; pattern: Obj; targets: Map<string, (e: Obj) => void> };

class Bus {
  rules: Rule[] = [];
  archive: Obj[] = [];
  private n = 0;

  rule(name: string, pattern: Obj, target: (e: Obj) => void): void {
    this.rules.push({ name, pattern, targets: new Map([[name + '-target', target]]) });
  }

  // Like PutEvents: the event gets id and time from the bus; every matching rule delivers to its targets.
  put(source: string, detailType: string, detail: Obj): string[] {
    const event = { version: '0', id: `evt-${++this.n}`, source, 'detail-type': detailType, time: `2026-11-12T10:0${this.n}:00Z`, detail };
    this.archive.push(event);
    return this.deliver(event, this.rules);
  }

  private deliver(event: Obj, rules: Rule[]): string[] {
    const hit = rules.filter((r) => matches(r.pattern, event));
    hit.forEach((r) => r.targets.forEach((f) => f(event)));
    return hit.map((r) => r.name);
  }

  // Replay: rereads the archive in the interval and delivers ONLY to the given rules, on the same bus.
  replay(from: string, to: string, onlyRules: string[]): number {
    const target = this.rules.filter((r) => onlyRules.includes(r.name));
    let n = 0;
    for (const e of this.archive.filter((x) => x.time >= from && x.time <= to)) n += this.deliver(e, target).length;
    return n;
  }
}

const bus = new Bus();
const seen: Record<string, string[]> = { vip: [], errors: [], all: [] };
bus.rule('vip-orders', { source: ['com.myapp.orders'], 'detail-type': ['OrderPlaced'],
  detail: { total: [{ numeric: ['>', 500] }], customerType: ['PREMIUM', 'VIP'] } }, (e) => seen.vip.push(e.id));
bus.rule('errors', { source: [{ prefix: 'com.myapp.' }], 'detail-type': [{ suffix: 'Failed' }] }, (e) => seen.errors.push(e.id));
bus.rule('no-test', { source: ['com.myapp.orders'], detail: { environment: [{ 'anything-but': ['test', 'staging'] }] } }, (e) => seen.all.push(e.id));

const order = (total: number, customerType: string, environment: string) => ({ total, customerType, environment });
console.log('1', bus.put('com.myapp.orders', 'OrderPlaced', order(750, 'VIP', 'prod')));
console.log('2', bus.put('com.myapp.orders', 'OrderPlaced', order(750, 'STANDARD', 'prod')));
console.log('3', bus.put('com.myapp.orders', 'OrderPlaced', order(900, 'PREMIUM', 'test')));
console.log('4', bus.put('com.myapp.payments', 'PaymentFailed', { orderId: 'o-9' }));
console.log('5', bus.put('com.otherapp.orders', 'OrderPlaced', order(900, 'VIP', 'prod')));
console.log('deliveries:', JSON.stringify(seen));

// A new consumer is born later: it is fed with the history, without repeating the effects of the others.
const fresh: string[] = [];
bus.rule('order-analytics', { 'detail-type': ['OrderPlaced'] }, (e) => fresh.push(e.id));
console.log('replay deliveries:', bus.replay('2026-11-12T10:01:00Z', '2026-11-12T10:03:59Z', ['order-analytics']), JSON.stringify(fresh));
console.log('deliveries after the replay:', JSON.stringify(seen));

// Output:
// 1 [ 'vip-orders', 'no-test' ]
// 2 [ 'no-test' ]
// 3 [ 'vip-orders' ]
// 4 [ 'errors' ]
// 5 []
// deliveries: {"vip":["evt-1","evt-3"],"errors":["evt-4"],"all":["evt-1","evt-2"]}
// replay deliveries: 3 ["evt-1","evt-2","evt-3"]
// deliveries after the replay: {"vip":["evt-1","evt-3"],"errors":["evt-4"],"all":["evt-1","evt-2"]}

Event 1 (750 euros, VIP customer, production) triggers vip-orders and no-test. Event 2 (standard customer) triggers only no-test, because the VIP pattern requires customerType to be one of two values. Event 3 is the opposite: a premium order of 900 euros but environment test, so the VIP rule takes it and no-test excludes it with anything-but. Event 4 has nothing to do with orders and is taken only by errors, thanks to the suffix Failed.

Event 5 triggers no rule: the source is com.otherapp.orders, and an event that no rule selects is delivered to nobody. The point about replay is in the last line: the new consumer order-analytics, born later, receives the three orders archived in the interval, while the deliveries of the other rules are identical to before. Without the restriction on the rules the replay would have written again to the consumers already served.

What the real service says

The program is a model; here are the behaviours of the service that I could not try and that are worth knowing, all from the official documentation (the pages are in the sources):

  • Publishing with PutEvents can succeed halfway: the response contains an outcome for each entry and a FailedEntryCount counter, so the caller must check it and resend the failed entries.
  • Publishing to a bus that does not exist still returns a positive response: the documentation says that the event finds no rules and is discarded, without being counted among the failures.
  • The total size of a PutEvents request must stay under 1 MB (1,048,576 bytes); for larger payloads the guide recommends saving the object in S3 and putting only the URL in the event.
  • If delivery to a target fails with a retryable error, EventBridge retries by default for 24 hours and up to 185 times with exponential backoff and jitter; after that the event is discarded, unless a DLQ queue is configured on the target.
  • A rule can have up to five targets within the quota indicated by the guide for each supported region.

These are five facts that the program above cannot model because they depend on the network and on the service, and each one changes the code of the producer or of the consumer. The first and the second are errors that pass in silence if you do not check the response; the fourth is the one that weighs most in the design of a consumer, because it says that the same event can arrive several times (see consumer idempotency) and that without a DLQ the definitive failures vanish (see the dead letter queue).

Schema registry, archive and replay: what they really do

The EventBridge schema registry discovers the schemas from the events passing through the bus and lets you download code bindings for the most used languages. The guide describes it as a way to speed up development, not as a validator: it does not reject events that do not respect the schema. If you want a check at publication, you write it in the producer; a system that declares “schema errors detected at publication” describes a different function from the documented one. The same guide warns that the values of some properties used to build the schemas may be masked with asterisks because they contain sensitive data.

On the archive the guide says that EventBridge removes events only when they exceed the retention period or when you delete the archive. On replay it specifies that you can replay events only to the same bus that emitted them and you can limit delivery to certain rules, as the program does. Replay is the tool to rebuild a read model or to test a new consumer; it is not a time machine: replayed events pass again through the rules and targets of today, so the consumer must tolerate seeing old events and must be idempotent.

When a content-based bus is not the choice

The documentation warns of a mistake not to be underestimated: it is possible to write rules that produce an infinite loop, for example a rule that reacts to a change of permissions and whose action modifies the permissions again. Content-based rules are convenient precisely because they are implicit, but this makes them hard to reconstruct: to know who receives an event you have to look at all the rules of the bus.

If your case is “one message, one consumer, retries” a queue is enough; if you need a simple fan-out, a topic is enough. The bus pays off when the recipients are many, change often and must decide based on the data. If instead you need delivery ordering, the current guide associates it with the Custom Event Bus and not with the Classic model used here; check which of the two you are using before being promised it.

What you can do now

You can write a content-based pattern and predict which events it selects, you know why an event without rules vanishes without an error, and you can use replay with a filter to feed a new consumer without disturbing the others. You also know what to check on the real service: the per-entry outcome of PutEvents, the retries with a final discard, the DLQ and the difference between schema discovery and validation. The next step is the comparison with SQS and SNS. For the user experience when a consumer lags behind the bus, see eventual consistency.

Sources

The matcher and the bus (pattern.ts, bus.ts) were run with Node.js v24.11.0; the outputs are the real ones. They are a model of the concepts, not the service: no call to AWS was made, and the matcher implements only a subset of the operators. Quotas vary by region and over time: recheck the quotas page before sizing.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects