---
title: 'Domain Events'
source: 'https://academia.sh/en/courses/domain-driven-design/domain-events'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:19+00:00'
license: 'CC BY-SA 4.0'
---

# Domain Events

The explicit model of meaningful business events: measuring, across two logs produced from the same sixty facts, how many of the domain expert's three questions are answered correctly with a single filter, counting how many events' fields have to be compared for the answer, and comparing the rate at which event names are found in the domain vocabulary and the log length.

All the measurements up to this point were about the model's state: which name shows which
concept, which invariant lives inside which boundary, by which path the object is
constructed. The domain also has things that happen — a shipment was priced, a contract
discount was removed, an amount was recalculated because the tariff changed. These facts may
have their own names in the code, or they may all pass under the label "record updated."

A fact that happens in the domain, is narrated in the past tense, and has a name in the
domain language is called a **domain event**. Publishing the event, delivering it to
listeners, and finalizing it together with the transaction were established and measured in
the Domain Events lesson of the Data Access Layer and Business Logic course. This lesson
does not enter that side. It asks a single question: where does the event's name come from?

## Setting Up the Measurement

For the measurement, a day's pricing history is written as sixty facts. Each fact has a
type, a shipment number, and a before-and-after snapshot. Both logs are produced from this
single source; the only difference between them is what is stored.

```sh
mkdir -p general domain
```

```js
// facts.mjs — a day's pricing history: 60 facts, the source for both logs
const FACT = ["shipment-priced", "contract-discount-removed", "volume-discount-added",
  "weighing-correction-applied", "repriced-for-tariff-change",
  "minimum-fee-applied"];
export const MINIMUM_FEE = 3990;

export const FACTS = [];
for (let i = 0; i < 60; i += 1) {
  const fact = FACT[i % 6];
  const shipmentNo = `G-5${101 + (i % 20)}`;
  const t = 4200 + (i % 5) * 900;
  let before;
  let after;
  if (fact === "shipment-priced") {
    before = { amount: 0, tariff: null, discounts: [] };
    after = { amount: t, tariff: "standard", discounts: [] };
  } else if (fact === "contract-discount-removed") {
    before = { amount: t, tariff: "standard", discounts: ["contract", "volume"] };
    after = { amount: t + 600, tariff: "standard", discounts: ["volume"] };
  } else if (fact === "volume-discount-added") {
    before = { amount: t, tariff: "standard", discounts: ["contract"] };
    after = { amount: t - 400, tariff: "standard", discounts: ["contract", "volume"] };
  } else if (fact === "weighing-correction-applied") {
    before = { amount: t, tariff: "standard", discounts: ["volume"] };
    after = { amount: i % 4 === 3 ? MINIMUM_FEE : t + 250, tariff: "standard",
      discounts: ["volume"] };
  } else if (fact === "repriced-for-tariff-change") {
    before = { amount: t, tariff: "standard", discounts: ["volume"] };
    after = { amount: t - 700, tariff: "regional", discounts: ["volume"] };
  } else {
    before = { amount: t, tariff: "regional", discounts: ["volume"] };
    after = { amount: MINIMUM_FEE, tariff: "regional", discounts: ["volume"] };
  }
  FACTS.push({ sequence: i + 1, fact, shipmentNo, before, after });
}
```

The first log reduces the event's name to one of two general names and narrates what
happened through a before-and-after snapshot. Viewed from the persistence side, this is the
most natural choice: every write is an update.

```js
// general/log.mjs — every fact takes one of the same two types, the payload is a before/after snapshot
import { FACTS } from "../facts.mjs";

export const LOG = FACTS.map((f) => ({
  type: f.before.tariff === null ? "ShipmentCreated" : "ShipmentUpdated",
  shipmentNo: f.shipmentNo,
  before: f.before,
  after: f.after,
}));
```

The second log takes the event's name from the domain language. No snapshot is needed: the
name says what happened, and the payload is only as much as that name requires.

```js
// domain/log.mjs — the event's type comes from the domain language, the payload is only what that name requires
import { FACTS } from "../facts.mjs";

const NAME = {
  "shipment-priced": "ShipmentPriced",
  "contract-discount-removed": "ContractDiscountRemoved",
  "volume-discount-added": "VolumeDiscountAdded",
  "weighing-correction-applied": "WeighingCorrectionApplied",
  "repriced-for-tariff-change": "RepricedForTariffChange",
  "minimum-fee-applied": "MinimumFeeApplied",
};

export const LOG = FACTS.map((f) => ({
  type: NAME[f.fact],
  shipmentNo: f.shipmentNo,
  amount: f.after.amount,
}));
```

## Measurement

The domain expert asks the log three questions: how many shipments later lost their
contract discount, how many times was a shipment repriced because the tariff changed, how
many times did the minimum fee take effect. Each question is answered, in both logs, with a
single filter, and the answer is compared against the actual count.

```js
// event-measure.mjs — how many correct answers and how many comparisons the expert's three questions need in each log
import { FACTS, MINIMUM_FEE } from "./facts.mjs";
import { LOG as GENERAL } from "./general/log.mjs";
import { LOG as DOMAIN } from "./domain/log.mjs";

const actual = (fact) => FACTS.filter((f) => f.fact === fact).length;
const discountsOf = (g) => g?.discounts ?? [];

const QUESTIONS = [
  ["shipment that lost its contract discount", "contract-discount-removed",
    (log) => log.filter((e) => discountsOf(e.before).includes("contract")
      && !discountsOf(e.after).includes("contract")).length,
    (log) => log.filter((e) => e.type === "ContractDiscountRemoved").length],
  ["repricing due to tariff change", "repriced-for-tariff-change",
    (log) => log.filter((e) => e.before.tariff !== e.after.tariff).length,
    (log) => log.filter((e) => e.type === "RepricedForTariffChange").length],
  ["minimum fee taking effect", "minimum-fee-applied",
    (log) => log.filter((e) => e.after.amount === MINIMUM_FEE).length,
    (log) => log.filter((e) => e.type === "MinimumFeeApplied").length],
];

let generalCorrect = 0;
let domainCorrect = 0;
for (const [question, fact, generalAnswer, domainAnswer] of QUESTIONS) {
  const a = actual(fact);
  const g = generalAnswer(GENERAL);
  const d = domainAnswer(DOMAIN);
  if (g === a) generalCorrect += 1;
  if (d === a) domainCorrect += 1;
  console.log(`${question}`);
  console.log(`  actual ${a}  general log ${g} ${g === a ? "correct" : "WRONG"}  ` +
    `domain log ${d} ${d === a ? "correct" : "WRONG"}`);
}
console.log(`correct with a single filter: general ${generalCorrect}/3, domain ${domainCorrect}/3`);

const withSnapshot = (log) => log.filter((e) => "before" in e).length;
console.log(`events with domain fields compared for 3 questions: general ${withSnapshot(GENERAL) * 3}, ` +
  `domain ${withSnapshot(DOMAIN) * 3}`);

// VOCABULARY: words used in the domain expert's sentences
const VOCABULARY = ["shipment", "tariff", "zone", "weight", "volume", "discount", "contract",
  "weighing", "correction", "minimum", "fee", "priced", "added", "removed",
  "applied", "repriced", "change", "for"];
const splitWords = (t) => t.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(" ");
for (const [name, log] of Object.entries({ general: GENERAL, domain: DOMAIN })) {
  const types = [...new Set(log.map((e) => e.type))];
  const inVocabulary = types.filter((t) =>
    splitWords(t).every((w) => VOCABULARY.some((v) => w.startsWith(v))));
  console.log(`${name}: event types ${types.length}, whose full name is in the domain vocabulary ` +
    `${inVocabulary.length}/${types.length}`);
  console.log(`  ${types.join(" ")}`);
}
console.log(`log length: general ${JSON.stringify(GENERAL).length}, ` +
  `domain ${JSON.stringify(DOMAIN).length}`);
```

```sh
node event-measure.mjs
```

```
shipment that lost its contract discount
  actual 10  general log 10 correct  domain log 10 correct
repricing due to tariff change
  actual 10  general log 20 WRONG  domain log 10 correct
minimum fee taking effect
  actual 10  general log 15 WRONG  domain log 10 correct
correct with a single filter: general 1/3, domain 3/3
events with domain fields compared for 3 questions: general 180, domain 0
general: event types 2, whose full name is in the domain vocabulary 0/2
  ShipmentCreated ShipmentUpdated
domain: event types 6, whose full name is in the domain vocabulary 6/6
  ShipmentPriced ContractDiscountRemoved VolumeDiscountAdded WeighingCorrectionApplied RepricedForTariffChange MinimumFeeApplied
log length: general 11031, domain 4091
```

The general log gives the correct number for only one of the three questions. For the
second question it says twenty, when repricing due to a tariff change actually happened ten
times; the difference comes from the fact that the initial pricing also turns the tariff
field from empty to filled. For the third question it says fifteen, when the minimum fee
actually took effect ten times; in five events, the amount was equalized with the minimum
fee as a result of a weighing correction, but the rule was not applied.

Both mistakes could be fixed by correcting the query: a condition that leaves out the
initial pricing, a condition that verifies the minimum-fee equality against another field.
What the measurement shows is where these corrections get written. Each correction buries,
inside the query, a piece of domain knowledge that is not in the log, and it cannot be
tested from the log. In the domain log, the same three questions are answered with a single
filter that looks at the type name, and all three come out correct.

The second line gives the cost in terms of operations: answering the three questions
compares the fields of one hundred eighty events in the general log, and none in the domain
log. The third measurement shows where the names come from: the names of the two types in
the general log are not in the domain vocabulary — "created" and "updated" are not words the
expert uses, they are the names of persistence operations. All six of the six types in the
domain log come from the domain vocabulary.

The last line is a side gain: the information carried by the name is shorter than the
information carried by the snapshot. The log length drops from 11031 characters to 4091.

## What the Event's Name Says

A domain event's name carries three things together: what happened, in which concept it
happened, and that it is over and done with. The name `ContractDiscountRemoved` gives all
three. The name is in the past tense because an event is not a request; wanting it to happen
is a different concept and has a different name.

The measurable consequence of the name belonging to the domain language is that the expert
can verify the log. When the six type names of the domain log are put in front of the
expert, the expert can name what is missing: "computing the return fee is a fact too, and it
is not on the list." When the two names of the general log are put in front of the expert,
there is nothing to say, because those names are not in the expert's language.

The event's payload goes through the same measure. That the payload of the
`RepricedForTariffChange` event carries the new amount is relevant to the domain; that it
would carry the record's database row identifier is not. When a field is added to a log, the
question asked is whether that field appears in the expert's sentence.

## When a General Name Is Enough

The cost of domain events is the number of types. Six types mean six names, six filter
conditions, and, over time, six separate versions to maintain; the general log has two
names, and a new fact type adds nothing to it.

The case in which this cost goes unpaid back is one where no domain question is asked of the
log. In a trace kept only to answer "when was this record last touched," a before-and-after
snapshot is enough, and separating types produces no gain. The measure of the distinction is
not the size of the log, either — it is the number of domain questions asked of the log.

The second case is one where the fact has no name in the domain. Inventing a domain event
name for changes the expert calls "not really anything, just a record correction" adds to
the vocabulary a concept with no domain counterpart; the measure from the second lesson
counts this as the number of concepts per name.

## Summary

- A domain event is a fact that happens in the domain and has a name in the domain language;
  this lesson measured only where the name comes from, and did not enter the publishing and
  delivery side.
- Of the two logs produced from the same sixty facts, the general log answered 1 of the
  expert's three questions correctly with a single filter, the domain log answered 3.
- The general log's two wrong answers can be corrected, but each correction buries, inside
  the query, a piece of domain knowledge that is not in the log, making it untestable from
  the log.
- Answering the three questions required comparing the fields of 180 events in the general
  log, and 0 in the domain log.
- Of the general log's 2 type names, 0 come from the domain vocabulary; of the domain log's
  6 type names, all 6 do; the log length dropped from 11031 to 4091 characters.
- The cost is the number of types; if no domain question is asked of the log, or if the fact
  has no name in the domain, a general name is enough.

## Next Step

The lessons in this topic measured identity, value, boundary, the location of behavior,
creation, and the event, one by one. Their common assumption was that a domain object has a
behavior. A common arrangement removes this assumption: domain classes carry only fields,
every rule is written into services, and the classes become nothing more than a data shape.
The next lesson measures this arrangement in the context of the domain model: it counts how
many services the same rule is repeated in, how many files are touched when a new rule is
added, and how many of the verbs in the expert's sentence are found as a method on the
domain object.
