Lesson 08 / 19
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.
Contents
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.
mkdir -p general domain
// 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.
// 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.
// 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.
// 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}`);
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.