---
title: 'Domain Events'
source: 'https://academia.sh/en/courses/data-access-layer/domain-events'
course: 'The Data Access Layer and Business Logic'
language: en
updated: '2026-08-19T05:19:33+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
mkdir -p domain application
```

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

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

```js
// 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}`);
}
```

```sh
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.

```sh
# 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.

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

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

```js
// 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}`);
```

```sh
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.
