Lesson 09 / 19
Anemic Domain Model Critique
Measuring behaviorless data classes in the context of the domain model: counting how many separate services the same five rules are written in and how many file edits the five rules require, counting how many times, across 36 shipments, services writing the same rule give a different result, and comparing how many of the domain expert's verbs can be asked of the shipment.
Contents
The lessons in this topic measured identity, value, the consistency boundary, the location of behavior, creation, and the event, one by one, sharing one assumption: that a domain object has a behavior. A common arrangement removes it — domain classes carry only fields, every rule is written into services, and the class becomes nothing more than a data shape.
This arrangement, which carries fields but no rules, is called the anemic model. Its critique from the standpoint of object responsibility was made in the Object Responsibility lesson of the Programming Paradigms course and is not repeated here. This lesson measures the same arrangement in the domain model’s context: how many services a rule is written in, how many file edits five rules require, whether services writing the same rule reach the same result for the same shipment, and how many verbs from the expert’s sentence the domain object answers.
Five Rules and Three Services
One sentence from the pricing expert carries five rules: the chargeable weight is the larger of the actual weight and the weight derived from volume; a contracted customer gets the contract discount; the contract and volume discounts together cannot exceed the discount cap; the fee never drops below the minimum fee; a declared-value shipment gets an insurance premium.
The library has three services that use these rules: the invoice line, the period-end reconciliation, and the bulk pricing list. The measurement data is thirty-six shipment records; in twelve, the contract number is empty yet a discount rate is written, and observing the second rule depends on it.
mkdir -p anemic domain
// data.mjs — the pricing context's tariff table and 36 shipment records export const TARIFF = { standard: { near: 1800, mid: 2400, far: 3200 }, heavy: { near: 1400, mid: 1900, far: 2600 }, }; export const RECORDS = []; let i = 0; for (const [weightGrams, volumeDm3] of [[700, 2], [2400, 9], [6000, 18], [18000, 60]]) for (const [contractNo, contractDiscount] of [[null, 0.22], ["S-77", 0.22], ["S-12", 0.3]]) for (const volumeDiscount of [0, 0.12, 0.2]) { i += 1; RECORDS.push({ shipmentNo: `G-6${100 + i}`, weightGrams, volumeDm3, feeZone: ["near", "mid", "far"][i % 3], tariffCode: weightGrams >= 10000 ? "heavy" : "standard", declaredValueCents: i % 3 === 0 ? 0 : 60000 * (1 + (i % 4)), contractNo, contractDiscount, volumeDiscount, }); }
The Anemic Arrangement
In the first arrangement, the shipment record is carried as is; no rule stays with it.
// anemic/shipment.mjs — carries fields only; no rule lives here export const shipment = (r) => ({ ...r });
The invoice service writes all five of the five rules within itself.
// anemic/invoice.mjs — writes all five rules itself export const invoiceLine = (g, tariff) => { const chargeableWeight = Math.max(g.weightGrams, g.volumeDm3 * 200); const contractRate = g.contractNo === null ? 0 : g.contractDiscount; const discount = Math.min(0.4, contractRate + g.volumeDiscount); const raw = Math.round((chargeableWeight / 1000) * tariff[g.tariffCode][g.feeZone] * (1 - discount)); const fee = Math.max(3990, raw); const premium = Math.round(g.declaredValueCents * 0.002); return { chargeableWeight, contractRate, discount, minimumApplied: fee > raw, premium, amount: fee + premium }; };
The reconciliation service writes the same five rules, applying the cap only to the contract discount.
// anemic/reconciliation.mjs — same five rules; the cap applied to the contract discount only export const reconciliationLine = (g, tariff) => { const chargeableWeight = Math.max(g.weightGrams, g.volumeDm3 * 200); const contractRate = g.contractNo === null ? 0 : g.contractDiscount; const discount = Math.min(0.4, contractRate) + g.volumeDiscount; const raw = Math.round((chargeableWeight / 1000) * tariff[g.tariffCode][g.feeZone] * (1 - discount)); const fee = Math.max(3990, raw); const premium = Math.round(g.declaredValueCents * 0.002); return { chargeableWeight, contractRate, discount, minimumApplied: fee > raw, premium, amount: fee + premium }; };
The bulk pricing service does not compute the insurance premium and takes the discount rate from the record as is; the contract condition is not in this file.
// anemic/bulk-pricing.mjs — the same rules once more; the contract condition is not written here export const listPrice = (g, tariff) => { const chargeableWeight = Math.max(g.weightGrams, g.volumeDm3 * 200); const contractRate = g.contractDiscount; const discount = Math.min(0.4, contractRate + g.volumeDiscount); const raw = Math.round((chargeableWeight / 1000) * tariff[g.tariffCode][g.feeZone] * (1 - discount)); const fee = Math.max(3990, raw); return { chargeableWeight, contractRate, discount, minimumApplied: fee > raw, pricePerKg: Math.round(fee / (chargeableWeight / 1000)) }; };
The Model That Carries Behavior
In the second arrangement, the five rules are on the shipment itself; the fields are closed, and only the rule results are exposed.
// domain/shipment.mjs — the five rules are on the shipment; fields are closed, only rule results are exposed const VOLUME_FACTOR = 200; const DISCOUNT_CAP = 0.4; const MINIMUM_FEE_CENTS = 3990; const INSURANCE_RATE = 0.002; export const shipment = (r) => { const chargeableWeightGrams = () => Math.max(r.weightGrams, r.volumeDm3 * VOLUME_FACTOR); const contractDiscountRate = () => (r.contractNo === null ? 0 : r.contractDiscount); const applicableDiscountRate = () => Math.min(DISCOUNT_CAP, contractDiscountRate() + r.volumeDiscount); const raw = (tariff) => Math.round((chargeableWeightGrams() / 1000) * tariff[r.tariffCode][r.feeZone] * (1 - applicableDiscountRate())); const feeCents = (tariff) => Math.max(MINIMUM_FEE_CENTS, raw(tariff)); const insurancePremiumCents = () => Math.round(r.declaredValueCents * INSURANCE_RATE); return Object.freeze({ shipmentNo: r.shipmentNo, chargeableWeightGrams, contractDiscountRate, applicableDiscountRate, feeCents, insurancePremiumCents, minimumFeeApplied: (tariff) => feeCents(tariff) > raw(tariff), amountCents: (tariff) => feeCents(tariff) + insurancePremiumCents(), }); };
The same three services no longer compute; they ask.
// domain/invoice.mjs — writes no rule, asks the shipment export const invoiceLine = (g, tariff) => ({ chargeableWeight: g.chargeableWeightGrams(), contractRate: g.contractDiscountRate(), discount: g.applicableDiscountRate(), minimumApplied: g.minimumFeeApplied(tariff), premium: g.insurancePremiumCents(), amount: g.amountCents(tariff), });
// domain/reconciliation.mjs — does not interpret the cap itself, takes the applicable rate export const reconciliationLine = (g, tariff) => ({ chargeableWeight: g.chargeableWeightGrams(), contractRate: g.contractDiscountRate(), discount: g.applicableDiscountRate(), minimumApplied: g.minimumFeeApplied(tariff), premium: g.insurancePremiumCents(), amount: g.amountCents(tariff), });
// domain/bulk-pricing.mjs — the price per kilogram is its own job, the rules are the shipment's export const listPrice = (g, tariff) => ({ chargeableWeight: g.chargeableWeightGrams(), contractRate: g.contractDiscountRate(), discount: g.applicableDiscountRate(), minimumApplied: g.minimumFeeApplied(tariff), pricePerKg: Math.round(g.feeCents(tariff) / (g.chargeableWeightGrams() / 1000)), });
In How Many Files Is a Rule Written
The first measurement searches both directories for each rule and counts the files it appears in. The patterns match the rule by both its numeric value and a named constant, so a renamed constant does not escape the scan. The same script also counts how many of the expert’s five verbs the shipment answers with a single call.
// rule-measure.mjs — how many files a rule is written in, how many edits five rules require, where a verb can be asked import { readdirSync, readFileSync } from "node:fs"; import { RECORDS } from "./data.mjs"; import { shipment } from "./anemic/shipment.mjs"; import { shipment as domainShipment } from "./domain/shipment.mjs"; const RULE = [ ["chargeable weight", /volumeDm3 \* (?:200|VOLUME_FACTOR)/], ["contract discount", /contractNo === null \? 0/], ["discount cap", /Math\.min\((?:0\.4|DISCOUNT_CAP)/], ["minimum fee", /Math\.max\((?:3990|MINIMUM_FEE_CENTS)/], ["insurance premium", /\* (?:0\.002|INSURANCE_RATE)/], ]; for (const dir of ["anemic", "domain"]) { const files = readdirSync(dir).sort().map((f) => [f, readFileSync(`${dir}/${f}`, "utf8")]); let total = 0; console.log(`${dir} (${files.length} files)`); for (const [name, pattern] of RULE) { const locations = files.filter(([, content]) => pattern.test(content)).map(([f]) => f); total += locations.length; console.log(` ${name.padEnd(20)} written in ${locations.length} file(s) ${locations.join(" ")}`); } console.log(` total file edits for 5 rules = ${total}`); } const VERB = [ ["finds the chargeable weight", "chargeableWeightGrams"], ["applies the contract discount", "contractDiscountRate"], ["does not exceed the discount cap", "applicableDiscountRate"], ["does not go below the minimum fee", "feeCents"], ["adds the insurance premium", "insurancePremiumCents"], ]; for (const [name, obj] of [["anemic", shipment(RECORDS[0])], ["domain", domainShipment(RECORDS[0])]]) { const askable = VERB.filter(([, method]) => typeof obj[method] === "function"); console.log(`${name}: of the expert's ${VERB.length} verbs, ${askable.length} can be asked of the shipment`); }
node rule-measure.mjs
anemic (4 files) chargeable weight written in 3 file(s) bulk-pricing.mjs invoice.mjs reconciliation.mjs contract discount written in 2 file(s) invoice.mjs reconciliation.mjs discount cap written in 3 file(s) bulk-pricing.mjs invoice.mjs reconciliation.mjs minimum fee written in 3 file(s) bulk-pricing.mjs invoice.mjs reconciliation.mjs insurance premium written in 2 file(s) invoice.mjs reconciliation.mjs total file edits for 5 rules = 13 domain (4 files) chargeable weight written in 1 file(s) shipment.mjs contract discount written in 1 file(s) shipment.mjs discount cap written in 1 file(s) shipment.mjs minimum fee written in 1 file(s) shipment.mjs insurance premium written in 1 file(s) shipment.mjs total file edits for 5 rules = 5 anemic: of the expert's 5 verbs, 0 can be asked of the shipment domain: of the expert's 5 verbs, 5 can be asked of the shipment
In the anemic arrangement, five rules were written with thirteen file edits, in the domain model with five. When a rule’s text changes, two or three files are edited in the anemic arrangement, one file in the domain model.
The two on the contract discount line is not a gain. The bulk pricing service also applies a discount, so it too must apply the contract condition; the number needed is fourteen, not thirteen. A count of how many places a rule is written in cannot distinguish an unwritten place from low repetition: the missing place shows up not as an absence, but as a low number. Only a measurement that compares the result tells the two apart.
The last two lines ask the same question from the language side. The expert’s five verbs cannot be asked of the anemic shipment, since that object has no methods at all. Their counterpart is three lines inside the services, and those lines have no name in the domain vocabulary.
When the Services Diverge
The second measurement looks at the result. Each rule has an observable output: the chargeable weight, the applied contract rate, the applicable discount rate, whether the minimum fee took effect, and the insurance premium. For each shipment, the values the services writing that rule produce are compared, and shipments producing more than one value are counted.
// divergence-measure.mjs — how many times do services writing the same rule give a different result across 36 shipments import { TARIFF, RECORDS } from "./data.mjs"; import { shipment } from "./anemic/shipment.mjs"; import { invoiceLine as aInvoice } from "./anemic/invoice.mjs"; import { reconciliationLine as aReconciliation } from "./anemic/reconciliation.mjs"; import { listPrice as aList } from "./anemic/bulk-pricing.mjs"; import { shipment as domainShipment } from "./domain/shipment.mjs"; import { invoiceLine as sInvoice } from "./domain/invoice.mjs"; import { reconciliationLine as sReconciliation } from "./domain/reconciliation.mjs"; import { listPrice as sList } from "./domain/bulk-pricing.mjs"; const RULE = [["chargeable weight", "chargeableWeight"], ["contract discount", "contractRate"], ["discount cap", "discount"], ["minimum fee", "minimumApplied"], ["insurance premium", "premium"]]; const N = RECORDS.length; const measure = (name, services, build) => { const results = RECORDS.map((r) => services.map((f) => f(build(r), TARIFF))); const divergedShipments = new Set(); console.log(name); for (const [ruleName, field] of RULE) { const writing = results[0].filter((res) => field in res).length; let diverged = 0; RECORDS.forEach((r, i) => { const values = new Set(results[i].filter((res) => field in res).map((res) => String(res[field]))); if (values.size > 1) { diverged += 1; divergedShipments.add(r.shipmentNo); } }); console.log(` ${ruleName.padEnd(20)} services writing it ${writing} diverged shipments ${diverged}/${N}`); } console.log(` diverged in at least one rule = ${divergedShipments.size}/${N}`); return results; }; const A = measure("anemic", [aInvoice, aReconciliation, aList], (r) => shipment(r)); measure("domain", [sInvoice, sReconciliation, sList], (r) => domainShipment(r)); const diverged = RECORDS.map((_, i) => i).filter((i) => A[i][0].discount !== A[i][1].discount); console.log(`invoice and reconciliation diverged discount rate = ${diverged.length}/${N}, first 3:`); for (const i of diverged.slice(0, 3)) { console.log(` ${RECORDS[i].shipmentNo} invoice ${A[i][0].discount.toFixed(2)} / ${A[i][0].amount}` + ` reconciliation ${A[i][1].discount.toFixed(2)} / ${A[i][1].amount}`); } const hidden = diverged.filter((i) => A[i][0].amount === A[i][1].amount).length; console.log(`diverged discount rate with same amount = ${hidden}/${diverged.length}`); let mismatch = 0; for (const r of RECORDS) { if (aInvoice(shipment(r), TARIFF).amount !== sInvoice(domainShipment(r), TARIFF).amount) mismatch += 1; } console.log(`invoice amount diverged between the two models = ${mismatch}/${N}`);
node divergence-measure.mjs
anemic chargeable weight services writing it 3 diverged shipments 0/36 contract discount services writing it 3 diverged shipments 12/36 discount cap services writing it 3 diverged shipments 24/36 minimum fee services writing it 3 diverged shipments 0/36 insurance premium services writing it 2 diverged shipments 0/36 diverged in at least one rule = 24/36 domain chargeable weight services writing it 3 diverged shipments 0/36 contract discount services writing it 3 diverged shipments 0/36 discount cap services writing it 3 diverged shipments 0/36 minimum fee services writing it 3 diverged shipments 0/36 insurance premium services writing it 2 diverged shipments 0/36 diverged in at least one rule = 0/36 invoice and reconciliation diverged discount rate = 12/36, first 3: G-6106 invoice 0.40 / 3990 reconciliation 0.42 / 3990 G-6108 invoice 0.40 / 4110 reconciliation 0.42 / 4110 G-6109 invoice 0.40 / 3990 reconciliation 0.50 / 3990 diverged discount rate with same amount = 5/12 invoice amount diverged between the two models = 0/36
In twenty-four of the thirty-six shipments, the three services diverge on at least one rule, from two sources. The twelve on the contract discount line is the cost of the place the scan showed as missing: with an empty contract number, bulk pricing applies a twenty-two-point contract discount while the other two count it as zero. The twenty-four on the discount cap line includes those twelve — a diverging contract rate drags the dependent cap with it — plus twelve more from reconciliation applying the cap only to the contract discount.
The sample rows show why the divergence went unnoticed. For shipment G-6106, the invoice
applies a forty percent discount, reconciliation forty-two, yet both amounts come out at
3990. In five of the twelve diverged shipments the amount matches, since the minimum fee
rule seats both computations on the same floor. The divergence did not disappear, it became
invisible: it surfaces in only seven shipments.
In the domain model, diverged shipments are zero for all five rules; the three services ask the same method, leaving no second writing to diverge from. The last line says what the measurement is not: a correctly written anemic service and the domain model give the same invoice amount. The gain is not that the computation changes.
Extracting the Rule into a Shared Module
The known fix for the anemic arrangement is extracting the rules into a shared helper module, equalizing the first measurement with the domain model’s: each rule now lives in one file. The second measurement is not solved by the module alone. The helper function takes fields as parameters, so the calling service decides the value to hand it; the bulk pricing service’s divergence arose from exactly this decision — not a mistyped rule, but the wrong rate handed to it. What follows is not how many files the rule is written in, but who decides its input. Asked of the domain object, the input is its own field, and the service has nothing left to hand it.
Where the Anemic Class Is in Its Place
The case where a behaviorless record carrying fields is in its place was measured in the Object Responsibility lesson: records crossing a boundary preserve shape, not interpret rules. This lesson adds two domain-model-specific distinctions on top of that boundary.
The first is who the rule belongs to. All five rules could be answered with a single shipment’s fields, which is why they were moved onto it. A rule requiring two aggregates together — a contract’s period volume affecting the discount, for instance — is not moved onto the shipment; it goes to the domain service established in the Domain Services lesson. The opposite of the anemic model is not “every rule inside the object.”
The second is cost: the shipment gained five methods and must take the tariff as a parameter. The measure lies in the number of rules — a record carrying an uninterpreted field can stay behaviorless, but the rule for an interpreted field must stay with it.
Summary
- The anemic model carries fields but no rules; this lesson measured it in the domain model’s context, without entering the object responsibility discussion.
- Five rules were written with 13 file edits in the anemic arrangement, with 5 in the domain model; changing one rule touches 2–3 files in the anemic arrangement, 1 file in the domain model.
- The 2 on the contract discount line was not a gain but a deficiency: the number needed was 14, and the missing place showed up in the scan as a low number.
- The 3 services writing the same rule diverged on at least one rule in 24 of the 36 shipments; in the domain model, diverged shipments were 0 for all five rules.
- The invoice’s and reconciliation’s discount rates diverged in 12 shipments, and in 5 of these the divergence did not surface because the minimum fee rule equalized the amounts.
- 0 of the expert’s 5 verbs answered from the anemic shipment with a single call, 5 from the domain shipment; the two models’ invoice amount diverged in 0 of 36 shipments.
Next Step
Throughout this topic, one model’s internal arrangement was established: which object holds
identity, which invariant lives inside which boundary, where behavior belongs, by which path
the object is created, where the event’s name comes from. Every decision was measured
against the model’s alignment with the domain. One question was not asked: a model of which
domain? In the shipment pricing and routing library, the word shipment names two different
objects in the mouths of two teams — a priceable unit carrying weight, volume, zone, and
declared value for pricing; a physical package with a route and status timestamps for
delivery. Because the word is the same, one class gets written and is forced to do two jobs
at once. The next topic measures this cost with two numbers: the fields the class carries,
and the field pairs that never appear together in any use.
To keep your progress and take notes, Log in
My notes
Log in to take notes.