---
title: 'Event-Driven Architecture'
source: 'https://academia.sh/en/courses/architectural-styles/event-driven-architecture'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:08+00:00'
license: 'CC BY-SA 4.0'
---

# Event-Driven Architecture

Reversing the direction of the call: counting the consumers the publisher knows and calls by name, measuring the line edited in the publisher file when a new consumer is added, computing how the bytes crossing the boundary and the transform points grow with the consumer count, comparing the number of requests answered when a consumer drops, and measuring the deviation between two consumer designs when the same event arrives twice.

In the three styles so far, the direction of the call never changed. Aggregation was a function
call in the monolithic arrangement, a call across a shared surface in the service-oriented
arrangement, and the combination of two network calls in the microservice arrangement. In each,
the caller knew the other unit by name, called it, and waited for a response. Every cost measured
came out of these two obligations.

**Event-driven architecture** reverses the direction of the call: a unit declares that something
has happened; it does not know who is listening and receives no return value. The pricing context
no longer calls the operation — it publishes a `shipment-priced` event. The domain event concept
comes from the Domain-Driven Design course, whose Event Sourcing lesson measured an event sequence
as a *persistence form*. The question here differs: what changes once an event carries a *system
arrangement* and crosses the system boundary.

## Three Differences in an Event That Crosses the Boundary

Inside the application, a domain event is an in-memory object. Publisher and handler share a call
stack: the publisher sees any error the handler throws and knows, once everything finishes, that
the work is complete. All three disappear at once when the event crosses the system boundary.

First, the event **serializes**: it becomes text, read again on the other side. From this point,
its field list is a published contract, and every field is an obligation whose version
compatibility must be tracked. Second, the publisher **receives no return value** — it knows the
announcement was made, not that the work was done. Third, the event's **arrival count cannot be
assumed to be 1**. Delivery semantics belong to the Caching, Queues and Asynchronous Processing
course; the architectural constraint here is that a consumer has to be designed for the same event
arriving twice.

The bus makes all three differences explicit. Publish–subscribe was established in the same
course; the implementation here is its smallest measurable form.

```js
// event/bus.mjs — event bus: serializes the publication, isolates each consumer, counts the crossings
export function eventBus(log) {
  const listener = new Map();
  return {
    listen(type, consumer) {
      if (listener.has(type) === false) listener.set(type, []);
      listener.get(type).push(consumer);
    },
    publish(type, body) {
      const text = JSON.stringify(body);
      log.transform += 1;
      log.event.push([type, text]);
      for (const c of listener.get(type) ?? []) {
        log.transform += 1;
        log.bytes += text.length;
        log.crossing.push(`${type} -> ${c.name}`);
        try { c.handle(JSON.parse(text)); } catch (e) { log.dropped.push(`${c.name}: ${e.message}`); }
      }
    },
  };
}
```

`publish` deliberately returns nothing and does not pass a consumer's error to the publisher.
Both decisions are the style itself, not a convenience.

The publisher does only its own work and hands off the event; no consumer name appears in this
file.

```js
// event/fee.mjs — pricing context: prices and publishes the event, does not know the listener
const TARIFF = { tier: [[1, 3000], [5, 4800], [20, 9600]], zone: { "34": 100, "06": 115, "35": 125 }, minimum: 2500 };

export function net(weight, zone, rate) {
  const tier = TARIFF.tier.find(([k]) => weight <= k) ?? [0, 9600];
  const base = Math.max(TARIFF.minimum, Math.round((tier[1] * TARIFF.zone[zone]) / 100));
  return base - Math.round(base * Math.min(rate, 0.4));
}

export function pricing(bus) {
  let sequence = 0;
  return {
    price(s) {
      sequence += 1;
      return bus.publish("shipment-priced", {
        eventNo: `E${sequence}`, id: s.id, weight: s.weight, zone: s.zone,
        contractNo: s.contractNo, net: net(s.weight, s.zone, s.rate),
      });
    },
  };
}
```

The `eventNo` field added to the contract is not the shipment's identifier but the event's own
identity — the counterpart of the third difference — and it is used in the measurement.

There are two consumers. The delivery operation writes the route under the shipment identifier;
if the same event arrives twice, the second write lands on the same key with the same value.

```js
// event/operation.mjs — delivery operation consumer: writes the route to the shipment identifier
const TREE = { "34": ["34"], "06": ["34", "06"], "35": ["34", "41", "35"] };

export function operationConsumer() {
  const plan = new Map();
  return {
    name: "operation",
    handle(event) {
      const route = TREE[event.zone] ?? ["34"];
      plan.set(event.id, { route, day: route.length, carrier: route.length > 2 ? "MT" : "AN" });
    },
    state() {
      return { plan: plan.size, day: [...plan.values()].reduce((t, p) => t + p.day, 0) };
    },
  };
}
```

The second consumer accumulates weight per contract for the volume discount. Accumulation is
inherently additive; below, two designs of the same work stand side by side.

```js
// event/contract.mjs — volume discount consumer: the accumulating and the keyed design for the same work
function volume(total) {
  return { contract: total.size, volume: [...total.values()].reduce((t, a) => t + a, 0) };
}

export function accumulator() {
  const total = new Map();
  return {
    name: "contract-accumulator",
    handle(event) {
      if (event.contractNo === null) throw new TypeError(`volume discount requires a contract: ${event.id}`);
      total.set(event.contractNo, (total.get(event.contractNo) ?? 0) + event.weight);
    },
    state() { return volume(total); },
  };
}

export function keyed() {
  const total = new Map();
  const processed = new Set();
  return {
    name: "contract-keyed",
    handle(event) {
      if (event.contractNo === null) throw new TypeError(`volume discount requires a contract: ${event.id}`);
      if (processed.has(event.eventNo)) return;
      processed.add(event.eventNo);
      total.set(event.contractNo, (total.get(event.contractNo) ?? 0) + event.weight);
    },
    state() { return volume(total); },
  };
}
```

For comparison, the same work is also written as a direct call. Both arrangements produce the
same result; where they diverge is what gets measured.

```js
// direct/fee.mjs — the same work with a direct call: pricing knows the consumers by name
import { net } from "../event/fee.mjs";
import { operationConsumer } from "../event/operation.mjs";
import { accumulator } from "../event/contract.mjs";

export function directPricing(log) {
  const operation = operationConsumer();
  const contract = accumulator();
  return {
    consumer: [operation, contract],
    price(s) {
      const event = {
        id: s.id, weight: s.weight, zone: s.zone,
        contractNo: s.contractNo, net: net(s.weight, s.zone, s.rate),
      };
      log.crossing.push("fee -> operation");
      operation.handle(event);
      log.crossing.push("fee -> contract");
      contract.handle(event);
      return { id: s.id, net: event.net, plan: operation.state().plan };
    },
  };
}
```

## Five Measurements

The driver script answers five questions in order: how many consumers the publisher knows and
calls by name; how many boundaries the same three shipments cross in the two arrangements, how
many transform points, and how many bytes; how many lines are edited in the publisher file when a
third consumer is added; how many requests are answered when a consumer drops; and how far the two
consumer designs deviate when the same event arrives twice.

```js
// event/measure.mjs — the awareness obligation, boundary measures, failure isolation, and replay deviation
import { readFileSync } from "node:fs";
import { eventBus } from "./bus.mjs";
import { pricing } from "./fee.mjs";
import { operationConsumer } from "./operation.mjs";
import { accumulator, keyed } from "./contract.mjs";
import { directPricing } from "../direct/fee.mjs";

const SHIPMENT = [
  { id: "G1", weight: 4, zone: "35", contractNo: "S7", rate: 0.15 },
  { id: "G2", weight: 1, zone: "34", contractNo: "S7", rate: 0 },
  { id: "G3", weight: 12, zone: "06", contractNo: "S9", rate: 0.25 },
];
const emptyLog = () => ({ crossing: [], transform: 0, bytes: 0, event: [], dropped: [] });
const setup = (log, consumers) => {
  const bus = eventBus(log);
  for (const c of consumers) bus.listen("shipment-priced", c);
  return [bus, pricing(bus)];
};

const IMPORT = /from\s+"[^"]*(operation|contract)\.mjs"/g;
for (const d of ["event/fee.mjs", "direct/fee.mjs"]) {
  const m = readFileSync(d, "utf8");
  console.log(`${d.padEnd(19)} known consumers = ${[...m.matchAll(IMPORT)].length}, called consumers = ${(m.match(/\.handle\(/g) ?? []).length}`);
}

const directLog = emptyLog();
const direct = directPricing(directLog);
const directResults = SHIPMENT.map((s) => direct.price(s));
console.log(`direct      : boundary crossing = ${directLog.crossing.length}, transform point = ${directLog.transform}, bytes = ${directLog.bytes}, return = ${directResults.every((d) => d === undefined) ? "none" : "object"}`);

const eventLog = emptyLog();
const [, eventFee] = setup(eventLog, [operationConsumer(), accumulator()]);
const eventResults = SHIPMENT.map((s) => eventFee.price(s));
console.log(`event driven: boundary crossing = ${eventLog.crossing.length}, transform point = ${eventLog.transform}, bytes = ${eventLog.bytes}, return = ${eventResults.every((d) => d === undefined) ? "none" : "object"}`);
console.log(`  event body = ${eventLog.event[0][1]}`);

const before = readFileSync("event/fee.mjs", "utf8");
const thirdLog = emptyLog();
const zoneCounter = {
  name: "zone-counter", count: new Map(),
  handle(e) { this.count.set(e.zone, (this.count.get(e.zone) ?? 0) + 1); },
};
const [, thirdFee] = setup(thirdLog, [operationConsumer(), accumulator(), zoneCounter]);
for (const s of SHIPMENT) thirdFee.price(s);
const edited = before === readFileSync("event/fee.mjs", "utf8") ? 0 : 1;
console.log(`third consumer: lines edited in publisher = ${edited}, lines added to composition root = 1, zones seen = ${zoneCounter.count.size}`);
console.log(`  with three consumers: boundary crossing = ${thirdLog.crossing.length}, transform point = ${thirdLog.transform}, bytes = ${thirdLog.bytes}`);

const NO_CONTRACT = [SHIPMENT[0], { id: "G2", weight: 1, zone: "34", contractNo: null, rate: 0 }, SHIPMENT[2]];
const directFailLog = emptyLog();
const directFail = directPricing(directFailLog);
let directAnswered = 0;
for (const s of NO_CONTRACT) {
  try { directFail.price(s); directAnswered += 1; } catch (e) { directFailLog.dropped.push(`publisher: ${e.message}`); }
}
console.log(`direct      : requests answered = ${directAnswered}/3, dropped = ${directFailLog.dropped.length} (${directFailLog.dropped.join("; ")})`);
console.log(`  operation = ${JSON.stringify(directFail.consumer[0].state())}, contract = ${JSON.stringify(directFail.consumer[1].state())}`);

const eventFailLog = emptyLog();
const eOp = operationConsumer();
const eContract = accumulator();
const [, eventFail] = setup(eventFailLog, [eOp, eContract]);
let eventAnswered = 0;
for (const s of NO_CONTRACT) {
  try { eventFail.price(s); eventAnswered += 1; } catch (e) { eventFailLog.dropped.push(`publisher: ${e.message}`); }
}
console.log(`event driven: requests answered = ${eventAnswered}/3, dropped = ${eventFailLog.dropped.length} (${eventFailLog.dropped.join("; ")})`);
console.log(`  operation = ${JSON.stringify(eOp.state())}, contract = ${JSON.stringify(eContract.state())}`);

const replayLog = emptyLog();
const rOp = operationConsumer();
const rAccumulator = accumulator();
const rKeyed = keyed();
const [replayBus, replayFee] = setup(replayLog, [rOp, rAccumulator, rKeyed]);
for (const s of SHIPMENT) replayFee.price(s);
const TRACKED = [[rOp, "operation", "day"], [rAccumulator, "contract-accumulator", "volume"], [rKeyed, "contract-keyed", "volume"]];
const before2 = TRACKED.map(([t, , k]) => t.state()[k]);
const [type, text] = replayLog.event[2];
replayBus.publish(type, JSON.parse(text));
console.log(`third event arrived a second time: ${text}`);
TRACKED.forEach(([t, name, k], i) => {
  const after = t.state()[k];
  console.log(`  ${name.padEnd(20)} ${k} before = ${before2[i]}, after = ${after}, deviation = ${after - before2[i]}`);
});

const source = readFileSync("event/contract.mjs", "utf8");
const lineCount = (name) => source.split(`export function ${name}()`)[1].split("\n}")[0].split("\n").filter((s) => s.trim() !== "").length;
console.log(`cost of the replay-resilient design = ${lineCount("keyed") - lineCount("accumulator")} lines`);
```

```sh
node event/measure.mjs
```

```
event/fee.mjs       known consumers = 0, called consumers = 0
direct/fee.mjs      known consumers = 2, called consumers = 2
direct      : boundary crossing = 6, transform point = 0, bytes = 0, return = object
event driven: boundary crossing = 6, transform point = 9, bytes = 470, return = none
  event body = {"eventNo":"E1","id":"G1","weight":4,"zone":"35","contractNo":"S7","net":5100}
third consumer: lines edited in publisher = 0, lines added to composition root = 1, zones seen = 3
  with three consumers: boundary crossing = 9, transform point = 12, bytes = 705
direct      : requests answered = 2/3, dropped = 1 (publisher: volume discount requires a contract: G2)
  operation = {"plan":3,"day":6}, contract = {"contract":2,"volume":16}
event driven: requests answered = 3/3, dropped = 1 (contract-accumulator: volume discount requires a contract: G2)
  operation = {"plan":3,"day":6}, contract = {"contract":2,"volume":16}
third event arrived a second time: {"eventNo":"E3","id":"G3","weight":12,"zone":"06","contractNo":"S9","net":8280}
  operation            day before = 6, after = 6, deviation = 0
  contract-accumulator volume before = 17, after = 29, deviation = 12
  contract-keyed       volume before = 17, after = 17, deviation = 0
cost of the replay-resilient design = 3 lines
```

## Reading the Numbers

The first two lines turn the awareness obligation into a number. The direct-call pricing file
imports 2 consumer modules and calls 2 by name; in the event-driven arrangement, both are 0. This
shows directly in the cost of extension: adding a third consumer edited 0 lines in the publisher
file and added 1 line to the composition root. The same addition in the direct arrangement needs
an import line, a field line, and a call line in the pricing file.

Boundary crossing is 6 in both arrangements — easy to miss, since the event-driven style does not
reduce the number of boundaries, only what happens at them. Transform point and bytes were both 0
in the direct arrangement; in the event-driven arrangement, transform point is 9 and bytes is 470.
Nine's structure is readable too: 1 serialization per publication and 1 deserialization per
consumer, $3 \times (1 + 2)$ for three shipments. With a third consumer this became
$3 \times (1 + 3) = 12$, and bytes rose from 470 to 705 — exactly $235 \times 3$. The publication
multiplier is linear and grows with the consumer count; the publisher's code stays constant.

The failure lines show the style's sharpest gain. The contract-free shipment threw an error in
the volume discount consumer. In the direct arrangement the error rose up the call stack and
dropped the request: requests answered was 2/3 — even though the operation consumer had *already*
written that shipment's route, so the plan count stayed at 3. Part of the system had done its
work while the request still looked like a failure. In the event-driven arrangement, requests
answered is 3/3; dropped consumers is still 1, but the dropped one's name was recorded and the
pricing response was unaffected. Consumer state came out identical in both arrangements
(`plan 3`, `volume 16`); only whose problem the error is differs.

This distinction has a cost. The `dropped` list showed up only because the bus ran in the same
process; once a consumer moves to its own deployment unit, that list sits somewhere the publisher
cannot reach.

## What Replay Loads Onto the Consumer

The last measurement is the bill for the third difference. Feeding the same event body to the bus
a second time left the operation consumer's day total at 6 — unchanged, because the route is
written under the shipment identifier and the second write overwrote the first with the same
value. The accumulating consumer's volume rose from 17 to 29, a deviation of 12, inflating the
contracted customer's volume discount; the defect is not in the bus but in the consumer's design.

The keyed design held at 17 under the same replay: the `eventNo` field in the contract lets the
consumer track which identifiers it has already processed. Its cost was measured at 3 lines — the
real obligation the style loads onto the consumer, since the publisher cannot guarantee the
arrival count and every accumulating consumer has to write its own replay resilience. These three
lines reappear in every accumulator as the consumer count grows.

## Where the Style Belongs

The numbers describe a quality attribute trade-off. Maintainability and reliability are gained:
lines edited in the publisher for a new consumer is 0, and requests answered when a consumer
drops is 3/3. End-to-end certainty is given up: the publisher publishes a 470-byte contract, gets
no return value, and cannot know at response time that the work was done.

This is why the style fits exchanges whose work need not finish at response time. Fee calculation
has to finish while a shipment is recorded; route planning and the volume discount do not. Turning
fee calculation into an event too would move the measurement's 2/3 line onto the pricing
response — which is why the choice is made per exchange, not for the system as a whole.

## Summary

- In event-driven architecture, a unit declares that something has happened; the publisher file
  measured 0 known consumers and 0 called by name, against 2 and 2 in the direct-call arrangement.
- When an event crosses the system boundary, three things change at once: the body serializes
  into a published contract, the publisher gets no return value, and the arrival count cannot be
  assumed to be 1.
- Boundary crossing stayed at 6 in both arrangements; what changes is what happens there —
  transform point rose from 0 to 9 and bytes from 0 to 470, becoming 12 and 705 with a third
  consumer.
- The third consumer was added with 0 lines edited in the publisher file; the cost is the 1 line
  in the composition root and the linearly growing publication multiplier.
- When a consumer threw an error, requests answered dropped to 2/3 in the direct arrangement and
  left work half done; the event-driven arrangement stayed at 3/3, recording the dropped
  consumer by name.
- When the same event arrived twice, the identifier-keyed operation consumer's deviation was 0
  and the accumulating consumer's deviation was 12; the keyed design costs 3 lines.

## Next Step

The three arrangements in this lesson share an implicit assumption: a process exists for pricing,
operation, and the bus, standing before any request arrives. In the microservice lesson, three
processes were started and stopped by hand; here, the bus also has to run somewhere. The next
style removes this assumption: the code reduces to a function called when an event or a request
arrives, and keeping a process standing moves outside the software. The next lesson counts what
that arrangement measures: the server lines the application itself carries, the number of
functions a request touches, where state that cannot be shared between functions goes, and how
shifting composition into configuration affects the number of files edited.
