Skip to content
academia.sh

Lesson 16 / 18

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.

Contents

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.

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

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

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

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

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

// 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`);
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×(1+2)3 \times (1 + 2) for three shipments. With a third consumer this became 3×(1+3)=123 \times (1 + 3) = 12, and bytes rose from 470 to 705 — exactly 235×3235 \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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close