Software Design Reference System Architect · Understand the Problem and the Domain

Lesson 0052 · System Architect · Module 1

Build the Domain Model and Its Language

The model is a shared simplification of the domain — and its words are a contract. Discover both from real scenarios before you write a class.

Reading ≈ 10 minPractice ≈ 30 min

Mission tie-in: the glossary of this course works only because every term has one agreed meaning. Your system's vocabulary deserves the same discipline — a shared vocabulary for the domain, precise enough to encode in code.

Knowledge: the model is a tool, not a transcript

In lesson 0051 you found the core subdomain and its experts. Now extract the knowledge: the rules, exceptions, and vocabulary those experts carry. Work from business scenarios — real, concrete cases, including the exceptional ones. A scenario is a story with a beginning, a decision, and an outcome; it is the raw material from which the model is built.

As the stories accumulate, a vocabulary emerges. Capture it as a glossary with one meaning per term, and then encode those words in code: classes, methods, and tests named by the language, not by implementation. When a support agent says "the return was refused," the domain object should have a refusal with reason — not a status == 4.

Scenarios → vocabulary → model
Expert's wordsGlossary entryEncoded as
“We refuse when the policy window passed.”Refusal: a decision with a reason code and the actor who made it.EligibilityDecision.refusal(reason)
“Except when the customer is VIP, we still accept.”Exception: a policy override with its own authority.PolicyOverride value object
“The label must match the approved return.”Label intent: a request for shipment tied to one approved return.label_for(approved_return)
The principle behind the principle

Naming is not decoration; it is where rules are stored. Every step between what an expert says and what the code does is a translation, and each translation is a place a rule can quietly fall out. refuse(reason) cannot be called without a reason, so the rule that every refusal has a defensible cause survives in the type system. status = 4 stores the conclusion and discards the cause, so the rule now lives only in whoever wrote that line. Multiply by twenty call sites and two years, and the difference is not stylistic: one system can answer “why was this refused?” and the other cannot.

The second mechanism is fidelity. A model is a decision-support tool, so its required accuracy is set by the decisions the software makes — not by how rich the business is. Detail the code never consults is carrying cost plus a lie surface: it drifts, nobody notices, and someone eventually trusts it. That is also the boundary condition. Where the software makes no domain decisions — a report exporter, an ETL job, a form over a table someone else owns — there is no second model to keep aligned, and inventing vocabulary for it is pure overhead. Adopt the source system's words instead. Likewise, where the vocabulary is fixed by law or by a standard, adopt it verbatim and translate at the edge; a "better" name for a legally defined term is a defect.

The model is a simplification, not a transcript of the business. You deliberately leave out what the software does not need — the physical shelf where parcels wait, the phone calls between desks. A model that mirrors every detail is as useless as one that mirrors none; both fail to support thought. What you keep is what matters for the decisions the software must make.

Watch for the words that mean different things to different people. "Approved" may mean policy eligibility to support, inspection passed to the warehouse, and money settled to finance. Each divergence is evidence of a separate model — a future boundary (lesson 0053), not a synonym problem to vote away. The glossary you build here is per-model, not per-company.

Keep the language alive: name classes after it, name tests after it, and write a scenario the way the expert said it. When code and expert disagree, investigate the discrepancy with concrete cases: the code may be stale, the rule may have changed, or two contexts may be using the same word differently.

# Encoded language: the refusal is a decision, not a status number.
from dataclasses import dataclass
from enum import Enum
from datetime import date, timedelta


class Reason(Enum):
    POLICY_WINDOW_PASSED = "policy window passed"
    EVIDENCE_MISSING = "evidence missing"


@dataclass(frozen=True)
class Refusal:
    reason: Reason
    decided_on: date
    decided_by: str


@dataclass(frozen=True)
class EligibilityDecision:
    accepted: bool
    refusal: Refusal | None = None

    def __post_init__(self) -> None:
        if self.accepted == (self.refusal is not None):
            raise ValueError("a decision must be accepted or refused, exclusively")


def decide(scenario: dict) -> EligibilityDecision:
    """One domain rule, in the language of the policy owner."""
    if scenario["requested_on"] - scenario["purchased_on"] > timedelta(days=30):
        return EligibilityDecision(False, Refusal(
            Reason.POLICY_WINDOW_PASSED, date.today(), "policy engine"))
    return EligibilityDecision(True)

Worked: reading the exceptions, not the nouns

Given a week with the returns experts, a beginner collects nouns: return, customer, order, label. An architect collects refusals, because the exceptions are where a domain hides its rules. The concrete move on this initiative was to pull a sample of 200 refusals from the support tool's free-text notes — one week of manual decisions — and sort them by what the agent actually wrote.

200 sampled refusals · what the free text was hiding
What the agent wroteCountWhat it turned out to be
“outside window” / “too late”61One rule, already codified — a reason code.
“no receipt”, “no photo”44One rule with a list — evidence requirements per category.
“not as described — judgement call”31Genuine judgement; needs a human and an audit trail.
“VIP — accepted anyway”24Not a refusal at all: an override with an authority.
everything else, once or twice40Unclassified; the queue that must shrink over time.

The question the architect asked of every row was the same: is this a rule or a judgement? The 24 VIP entries answered it loudest. They were filed as refusals and then reversed by hand, which means the vocabulary had no word for what actually happened. The expert's own sentence — “except when the customer is VIP, we still accept” — names a policy override, and an override has something a reason code does not: an authority who may issue it and a record of why. Encoding it as a refusal that someone edits afterwards loses both.

The alternative the team rejected was the obvious one: keep reason as free text, because the agents like writing sentences and an enum will always be incomplete. It was rejected for a reason that has nothing to do with tidiness. The initiative's guardrail is a manual review queue of at most 50 cases a day, and you cannot manage what you cannot count: with free text, “how many of last week's manual decisions were already codifiable?” is a data-cleaning project, not a query. The compromise they shipped keeps both — a required reason code and an optional note — with the unclassified bucket as the backlog the policy owner works down.

# The vocabulary, encoded: a refusal carries its cause, an override its authority.
from dataclasses import dataclass
from enum import Enum


class Reason(Enum):
    POLICY_WINDOW_PASSED = "policy window passed"
    EVIDENCE_MISSING = "evidence missing"
    CONDITION_DISPUTED = "condition disputed"     # the judgement bucket
    UNCLASSIFIED = "unclassified"                 # the backlog, counted honestly


@dataclass(frozen=True)
class PolicyOverride:
    """“Except when the customer is VIP, we still accept.” — the policy owner."""

    granted_by: str          # an authority, not a checkbox
    justification: str


def codifiable_share(refusals: list[Reason]) -> float:
    """The number the policy owner asks for every month."""
    codified = sum(1 for r in refusals
                   if r not in (Reason.CONDITION_DISPUTED, Reason.UNCLASSIFIED))
    return codified / len(refusals)


SAMPLE = ([Reason.POLICY_WINDOW_PASSED] * 61 + [Reason.EVIDENCE_MISSING] * 44
          + [Reason.CONDITION_DISPUTED] * 31 + [Reason.UNCLASSIFIED] * 40)
assert round(codifiable_share(SAMPLE), 2) == 0.6   # 105 of the 176 real refusals

A year later that enum is what makes a policy question answerable in an afternoon: “how many refusals would flip if we moved the window from 30 days to 45?” is a filter over one column. The same question against free text is a week of somebody reading. The vocabulary you choose is the query surface you will have.

Field notes · language smells
You hearIt revealsThe move
“Approved means different things here.”Two models are present.Record both meanings; keep them apart.
“It's basically like…”A concept is still fuzzy.Ask for the last concrete case.
“The system calls it X, we call it Y.”The code drifted from the language.Rename toward the expert's word.
“Usually, except when…”A rule and its exception.Model the rule and the override separately.
What people get wrong after reading this
“A shared language means the whole company uses the same words.”
It means one model has one meaning per term. Forcing finance, the warehouse, and support to agree on a single definition of “approved” destroys the most useful signal you have: that three models exist. Unify the words and you will spend the next two years debugging the places where they were never the same thing.
“The model should be as faithful to the business as possible.”
Fidelity is a cost, not a virtue. Model what the software decides; leave out the shelf the parcel sits on. Unused detail rots silently and then gets trusted by someone who does not know it is stale.
“Domain naming is a style preference — the logic is the same either way.”
The logic is the same on the day it ships. The difference appears when the rule changes: a name that carries the reason gives the next reader somewhere to look, and a name that carries only the conclusion sends them to read every call site.
“The glossary is a kickoff artifact.”
It is a living contract with the code. When an expert's word and the code's word disagree, one of them has changed; find out which before renaming anything. Ask for the last real case — arguments about definitions are resolved by examples, not by seniority.

When this is the wrong move. Do not build a model and a vocabulary for work that owns no decisions. In an integration or reporting job, the source system's vocabulary is the domain language; inventing your own adds a translation layer with nothing on the far side of it. In a genuinely generic subdomain, speak the vendor's language inside the adapter — a carrier calls it a “consignment”, and renaming it to please your model just makes their support tickets harder to read. And in a throwaway experiment, the scenarios are worth gathering but the glossary is not worth maintaining; write down what you learned and delete the code.

Skill: hear the model in the stories

The shared vocabulary is owned by…

The domain model's main purpose is…

Two experts use “approved” differently. The move is…

Practice: extract the language from three stories

Take a core subdomain you mapped in lesson 0051. Use three real scenarios from an expert, or use the three returns statements in the worked table above as your supplied case. For each: preserve the speaker's words, extract the nouns and verbs, and build a small glossary with one precise meaning per term. Mark words that could mean different things in another context. Then encode two important rules as named domain functions and write one counterexample for each.

In the lab and the capstone: the companion lab's phase-5 checkpoint gives you a returns-resolution service whose names came from the database rather than from anyone who does the work. Before restructuring it, write the glossary the code implies, then the glossary the scenarios imply, and mark every place they disagree — those marks are your refactoring list. The worked capstone carries the same vocabulary through its workflow and drivers artifacts, so you can see which words survived all the way to a decision.

Reveal: the glossary test

Read a term to two people and ask each to describe it in their own words. If they differ, the entry is too vague — sharpen it or split it. A glossary entry that cannot be paraphrased consistently is not yet domain knowledge; it is a word.

Teach it back
  1. Explain to a senior engineer, in two sentences, why a domain vocabulary is a defect-prevention mechanism rather than a naming convention.
  2. Pick one system you own. Find a field whose values are numbers or booleans standing in for a business decision, and write what the expert would call each value — then say what that rename would let you query.
  3. Predict the damage: if two teams keep using one word for two meanings for a year, name the specific bug that ships, and who reports it.

Your win

You can extract a working vocabulary from real scenarios, encode it in code and tests, and notice when divergent meanings reveal that more than one model exists.

Read and watch deeper

Give your agent-teacher the anonymized scenario transcript and ask it to draft the glossary — then treat every term it invents as unverified until the expert confirms or corrects it.