Skip to content
academia.sh

Lesson 20 / 21

Domain Events

Loosely coupling side effects through event publication: attaching more than one listener to the same publish call, counting the wrong notifications an event published inside a transaction produces after a rollback, and bringing that count to zero with the outbox pattern.

Contents

The loan request passed through both validation layers and the record is ready to be written. Other work needs to start along with the write: the member should be notified, a daily statistic should be updated, a line should be written to the log for future reports.

Writing all of this into the body of the loan-issuing function means changing that function on every new side effect. A domain event loosens the link between them: the loan-issuing function announces what happened and does not know who will act on it. This lesson’s actual subject is the timing of that announcement — when is publishing the event correct?

Event, Dispatcher, Listener

An event describes a fact in the past tense: “the loan was issued”. It is not a request and not a command; it is the record of something that already happened. The dispatcher calls the listeners bound to the event type. The lesson’s files sit in two directories.

mkdir -p domain application
// domain/event-dispatcher.mjs — the smallest dispatcher that calls listeners by event type
export class EventDispatcher {
  constructor() { this.listeners = new Map(); }
  subscribe(type, listener) {
    if (!this.listeners.has(type)) this.listeners.set(type, []);
    this.listeners.get(type).push(listener);
    return this;
  }
  publish(event) {
    for (const l of this.listeners.get(event.type) ?? []) l(event);
    return (this.listeners.get(event.type) ?? []).length;
  }
}

Listeners are the side effects. Both take the same event, and neither knows about the other.

// application/listeners.mjs — three side effects that react to the same event
export const notificationListener = (box) => (event) =>
  box.push({ loanId: event.loanId, memberId: event.memberId, bookId: event.bookId,
             text: `Book ${event.bookId} was issued on ${event.date}.` });

export const statsListener = (counts) => (event) => {
  counts[event.date] = (counts[event.date] ?? 0) + 1;
};

export const logListener = (lines) => (event) =>
  lines.push(`${event.date} ${event.type} loan=${event.loanId}`);

The measure of loose coupling is this: when a listener is added, the producing side’s code does not change.

// publish.mjs — the same publish call, a different number of listeners
import { EventDispatcher } from "./domain/event-dispatcher.mjs";
import { notificationListener, statsListener, logListener }
  from "./application/listeners.mjs";

const EVENTS = [
  { type: "loan_issued", loanId: 1, memberId: 1, bookId: 5, date: "2025-06-20" },
  { type: "loan_issued", loanId: 2, memberId: 3, bookId: 7, date: "2025-06-20" },
];

for (const listenerCount of [2, 3]) {
  const box = [], counts = {}, lines = [];
  const dispatcher = new EventDispatcher()
    .subscribe("loan_issued", notificationListener(box))
    .subscribe("loan_issued", statsListener(counts));
  if (listenerCount === 3) dispatcher.subscribe("loan_issued", logListener(lines));

  // The producing side runs the same single line in both cases.
  for (const event of EVENTS) dispatcher.publish(event);

  console.log(`listeners=${listenerCount}  notifications=${box.length}  ` +
              `stats=${JSON.stringify(counts)}  log=${lines.length}`);
}
node publish.mjs
listeners=2  notifications=2  stats={"2025-06-20":2}  log=0
listeners=3  notifications=2  stats={"2025-06-20":2}  log=2

The third side effect was added with no change to the line that publishes. The subscription is wired in the application layer; the domain layer only produces the event itself and does not know who subscribed to it.

The Event’s Relationship to the Transaction Boundary

Where the publish is placed is a subtler problem than loose coupling. In the database below, issuing a loan performs two writes: the record is added and the book’s stock drops by one. Stock cannot go below zero; this constraint makes some requests fail on the second step.

# setup.sh — sets up the lesson database from scratch
rm -f library.db
sqlite3 library.db <<'SQL'
CREATE TABLE member (member_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL,
                    stock INTEGER NOT NULL CHECK (stock >= 0));
CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL,
                    member_id INTEGER NOT NULL, pickup_date TEXT NOT NULL, return_date TEXT);
CREATE TABLE outbox (message_id INTEGER PRIMARY KEY, body TEXT NOT NULL,
                      status TEXT NOT NULL DEFAULT 'pending');
INSERT INTO member VALUES (1,'Alice Kane'),(2,'Ben Ortiz');
INSERT INTO book VALUES (1,'Blindness',1),(2,'The Disconnected',0),
                         (3,'The Book of Sand',1),(4,'Puslu Kitalar',0);
SQL

The first version publishes the event as soon as the record is added, that is, inside the transaction boundary.

// in-transaction.mjs — the event is published inside the transaction; the listener already ran even if it is rolled back
import { DatabaseSync } from "node:sqlite";
import { EventDispatcher } from "./domain/event-dispatcher.mjs";
import { notificationListener, statsListener } from "./application/listeners.mjs";

const db = new DatabaseSync("library.db");
const box = [], counts = {};
const dispatcher = new EventDispatcher()
  .subscribe("loan_issued", notificationListener(box))
  .subscribe("loan_issued", statsListener(counts));

function issueLoan(memberId, bookId, date) {
  db.exec("BEGIN");
  try {
    const loanId = Number(db.prepare(
      "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,?,NULL)")
      .run(bookId, memberId, date).lastInsertRowid);
    dispatcher.publish({ type: "loan_issued", loanId, memberId, bookId, date });
    db.prepare("UPDATE book SET stock = stock - 1 WHERE book_id = ?").run(bookId);
    db.exec("COMMIT");
    return `issued (loan ${loanId})`;
  } catch (e) {
    db.exec("ROLLBACK");
    return `rolled back (${e.message.split("\n")[0]})`;
  }
}

const REQUESTS = [[1, 1], [1, 2], [2, 3], [2, 4]];
for (const [memberId, bookId] of REQUESTS) {
  console.log(`member ${memberId} book ${bookId}: ${issueLoan(memberId, bookId, "2025-06-20")}`);
}

// A notification is wrong if it has no counterpart in the database.
const wrong = box.filter((n) => db.prepare(
  "SELECT 1 FROM loan WHERE loan_id = ? AND book_id = ?").get(n.loanId, n.bookId) === undefined);
console.log(`loan rows = ${db.prepare("SELECT count(*) AS n FROM loan").get().n}`);
console.log(`notifications sent = ${box.length}, wrong notifications = ${wrong.length}`);
console.log(`wrong ones: ${wrong.map((n) => `loan=${n.loanId} book=${n.bookId}`).join("  ")}`);
console.log(`stats = ${JSON.stringify(counts)}`);
sh setup.sh
node in-transaction.mjs
member 1 book 1: issued (loan 1)
member 1 book 2: rolled back (CHECK constraint failed: stock >= 0)
member 2 book 3: issued (loan 2)
member 2 book 4: rolled back (CHECK constraint failed: stock >= 0)
loan rows = 2
notifications sent = 4, wrong notifications = 2
wrong ones: loan=2 book=2  loan=3 book=4
stats = {"2025-06-20":4}

The database holds two records, four notifications were sent. Two of them went out for loans that never existed: two members received notifications for books that were never issued to them. The stats counted all four too; the daily report shows twice the truth.

One of the wrong ones is worth a second look. The identifier in the loan=2 book=2 line exists in the database, but it belongs to book number three. The identifier the rolled-back transaction used was freed, and the next record took it. Checking the notification by identifier alone would have missed this error; the check depends on holding the identifier and the book together.

The source of the problem is where the publish sits. When the listener was called the transaction had not yet committed, and whether it ever would was not decided. Sending a notification is not reversible — once a message is gone, it is gone. So the transaction’s atomicity covered only the database writes, not the side effects.

The Outbox

The fix is the same arrangement built in the Transactions topic: the event is written to a table inside the same transaction, not directly to the listener. If the transaction commits, the message is there; if it rolls back, it is not. Sending happens in a separate step, after the commit.

// outbox.mjs — the event is written to the table in the same transaction, sent to listeners after it commits
import { DatabaseSync } from "node:sqlite";
import { EventDispatcher } from "./domain/event-dispatcher.mjs";
import { notificationListener, statsListener } from "./application/listeners.mjs";

const db = new DatabaseSync("library.db");
const box = [], counts = {};
const dispatcher = new EventDispatcher()
  .subscribe("loan_issued", notificationListener(box))
  .subscribe("loan_issued", statsListener(counts));

function issueLoan(memberId, bookId, date) {
  db.exec("BEGIN");
  try {
    const loanId = Number(db.prepare(
      "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,?,NULL)")
      .run(bookId, memberId, date).lastInsertRowid);
    const event = { type: "loan_issued", loanId, memberId, bookId, date };
    db.prepare("INSERT INTO outbox (body) VALUES (?)").run(JSON.stringify(event));
    db.prepare("UPDATE book SET stock = stock - 1 WHERE book_id = ?").run(bookId);
    db.exec("COMMIT");
    return `issued (loan ${loanId})`;
  } catch (e) {
    db.exec("ROLLBACK");
    return `rolled back (${e.message.split("\n")[0]})`;
  }
}

function drainOutbox() {
  const pending = db.prepare(
    "SELECT message_id, body FROM outbox WHERE status = 'pending' ORDER BY message_id").all();
  for (const m of pending) {
    dispatcher.publish(JSON.parse(m.body));
    db.prepare("UPDATE outbox SET status = 'sent' WHERE message_id = ?").run(m.message_id);
  }
  return pending.length;
}

const REQUESTS = [[1, 1], [1, 2], [2, 3], [2, 4]];
for (const [memberId, bookId] of REQUESTS) {
  console.log(`member ${memberId} book ${bookId}: ${issueLoan(memberId, bookId, "2025-06-20")}`);
}
console.log(`pending in outbox = ${db.prepare(
  "SELECT count(*) AS n FROM outbox WHERE status = 'pending'").get().n}`);
console.log(`messages sent = ${drainOutbox()}`);

const wrong = box.filter((n) => db.prepare(
  "SELECT 1 FROM loan WHERE loan_id = ? AND book_id = ?").get(n.loanId, n.bookId) === undefined);
console.log(`loan rows = ${db.prepare("SELECT count(*) AS n FROM loan").get().n}`);
console.log(`notifications sent = ${box.length}, wrong notifications = ${wrong.length}`);
console.log(`stats = ${JSON.stringify(counts)}`);
sh setup.sh
node outbox.mjs
member 1 book 1: issued (loan 1)
member 1 book 2: rolled back (CHECK constraint failed: stock >= 0)
member 2 book 3: issued (loan 2)
member 2 book 4: rolled back (CHECK constraint failed: stock >= 0)
pending in outbox = 2
messages sent = 2
loan rows = 2
notifications sent = 2, wrong notifications = 0
stats = {"2025-06-20":2}

The requests are the same, the outcomes are the same; the wrong notification count dropped from two to zero. The outbox rows for the two rolled-back transactions were rolled back too, so there was no message left to send. The stats now say two as well.

What is gained is that the event describes a committed fact. An event published inside the transaction is not a fact, it is a prediction; an event in the outbox is the record of something already written to the database.

A Side Effect Must Not Determine the Main Operation

Where the publish sits solves a second problem too. If the listener is called directly, the listener’s failure invalidates the loan operation. The script below tries both arrangements for the case where the notification service does not respond for one book.

// listener-error.mjs — if the notification send fails, what happens to the loan record
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";
import { EventDispatcher } from "./domain/event-dispatcher.mjs";

function setup(file) {
  rmSync(file, { force: true });
  const db = new DatabaseSync(file);
  db.exec(`CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL,
                               member_id INTEGER NOT NULL, pickup_date TEXT NOT NULL, return_date TEXT);
           CREATE TABLE outbox (message_id INTEGER PRIMARY KEY, body TEXT NOT NULL,
                                 status TEXT NOT NULL DEFAULT 'pending');`);
  return db;
}

// The notification service fails once, for book 3.
const faultyListener = (box, faulty) => (event) => {
  if (faulty.has(event.bookId)) throw new Error(`notification service did not respond (book ${event.bookId})`);
  box.push(event.loanId);
};

const REQUESTS = [[1, 1], [2, 3], [1, 5]];

function directPublish() {
  const db = setup("direct.db");
  const box = [], faulty = new Set([3]);
  const d = new EventDispatcher().subscribe("loan_issued", faultyListener(box, faulty));
  for (const [memberId, bookId] of REQUESTS) {
    db.exec("BEGIN");
    try {
      const loanId = Number(db.prepare(
        "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,'2025-06-20',NULL)")
        .run(bookId, memberId).lastInsertRowid);
      d.publish({ type: "loan_issued", loanId, memberId, bookId });
      db.exec("COMMIT");
    } catch { db.exec("ROLLBACK"); }
  }
  return { loan: db.prepare("SELECT count(*) AS n FROM loan").get().n, notifications: box.length };
}

function withOutbox() {
  const db = setup("boxed.db");
  const box = [], faulty = new Set([3]);
  const d = new EventDispatcher().subscribe("loan_issued", faultyListener(box, faulty));
  for (const [memberId, bookId] of REQUESTS) {
    db.exec("BEGIN");
    const loanId = Number(db.prepare(
      "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,'2025-06-20',NULL)")
      .run(bookId, memberId).lastInsertRowid);
    db.prepare("INSERT INTO outbox (body) VALUES (?)")
      .run(JSON.stringify({ type: "loan_issued", loanId, memberId, bookId }));
    db.exec("COMMIT");
  }
  const drain = () => {
    for (const m of db.prepare(
      "SELECT message_id, body FROM outbox WHERE status = 'pending' ORDER BY message_id").all()) {
      try {
        d.publish(JSON.parse(m.body));
        db.prepare("UPDATE outbox SET status = 'sent' WHERE message_id = ?").run(m.message_id);
      } catch { /* the message stays pending, retried on the next round */ }
    }
    return db.prepare("SELECT count(*) AS n FROM outbox WHERE status = 'pending'").get().n;
  };
  const firstRound = drain();
  faulty.clear();                       // notification service came back up
  const secondRound = drain();
  return { loan: db.prepare("SELECT count(*) AS n FROM loan").get().n,
           notifications: box.length, firstRound, secondRound };
}

const a = directPublish();
console.log(`direct publish : loan rows=${a.loan}  notifications=${a.notifications}`);
const b = withOutbox();
console.log(`outbox         : loan rows=${b.loan}  notifications=${b.notifications}  ` +
            `pending messages: first round=${b.firstRound} second round=${b.secondRound}`);
node listener-error.mjs
direct publish : loan rows=2  notifications=2
outbox         : loan rows=3  notifications=3  pending messages: first round=1 second round=0

In direct publish, two of the three requests were recorded. The third book’s loan was lost even though nothing was wrong with the book or the member; a rolled-back record was decided by the notification service being unreachable. In the outbox, all three records were written; one message could not be sent on the first round and stayed pending, and was sent on the second round once the service was reachable again.

The separation is that persistence and delivery carry different guarantees. Writing the loan record is the transaction’s guarantee; sending the notification is work that will happen eventually. The condition from the Idempotent Transactions lesson holds here too: because a pending message is retried, a listener can receive the same event twice, and that second delivery must not produce a second effect.

The Limits of the Event

Event publication is not free; it has two traps.

The first is implicit control flow. Someone reading the loan-issuing function sees the publish line but cannot see what happens next; the subscriptions live in another file. If a rule is genuinely a mandatory part of the workflow — updating the book’s status, for example — binding it to an event makes the flow invisible. The test is this: work that is necessary for the operation’s correctness is not handed to a listener. Work handed to a listener is work whose absence still leaves the loan record valid.

The second is ordering and timing. In the outbox arrangement the listener runs after the transaction; the data may have changed in the time between. The listener should either be satisfied with the information the event carries, or read the data again and act on the current state. This is why putting decision-relevant information inside the event pays off: because loanId and bookId were inside the event, the listener could build the notification text without running a query.

Summary

  • A domain event describes a fact in the past tense; the publishing side does not know who subscribed. When a third listener was added, the publishing line did not change.
  • When the event was published inside the transaction, notifications went out for both rolled-back transactions: two records produced four notifications, two of them wrong; the stats counted twice the truth.
  • Because the rolled-back transaction’s identifier was freed, one of the wrong notifications carried an identifier that existed but belonged to another book; the check had to hold the identifier and the book together.
  • When the event was written to the outbox table in the same transaction and sent after it committed, the wrong notification count dropped to zero.
  • When the listener was called directly, the notification service’s failure rolled back a valid loan record; with the outbox the record was written, the message stayed pending, and it was sent on the next round.

Next Step

The four lessons in this topic isolated the domain layer step by step: separated from infrastructure by the dependency direction rule, from the outer contract by data transfer objects, from the input’s shape by the validation split, from side effects by events. One last question remains. The domain rules still take a repository, still run inside a transaction boundary, and still need a database file to be tested. Do the rules themselves need any of that? The course’s last lesson writes the business rules as pure functions, tests them without a database, compares the testing time against the same rule’s database-coupled version, and shows with a scan that no persistence trace remains in the domain module.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close