Software Architecture · Domain Modeling

Ubiquitous Language: Keeping One Term for One Concept in Code

How naming drift between speech and source produces defects that survive testing, and what it takes to make the code use the same words the domain does.

A conversation about “the account” can proceed for twenty minutes before anyone notices that the support engineer means the login, the accountant means the billing relationship, and the database means a row that holds both. In speech the confusion resolves itself through context and correction. In code it does not: a method named getAccount that returns a login will keep returning a login forever, and every reader who assumes otherwise is wrong in a way the compiler cannot detect. The vocabulary is part of the model, and when it drifts the model drifts with it.

Language is the interface to the model

The practical value of a domain model comes from the fact that domain experts can read it and say whether it is right. That only works if the names in the code are the names the experts use. A type called TxnRec with a field amt is not the same model as a type called Payment with a field amount, even when the fields hold identical bits, because the first cannot be checked by anyone who did not write it. The mapping from business term to symbol has to be one-to-one and stable, or every reading requires a translation step, and translations are where meaning is lost.

The failure is not stylistic. Consider a system that uses “customer” in the sales module, “client” in the billing module, and “account holder” in the reporting module, all denoting the same entity. Three teams write three queries, three sets of validation rules, and three identifiers. When the business decides that a customer may have several billing profiles, two of the three models are wrong, and nothing points at the discrepancy except a report that does not reconcile. The cost is not the extra typing. It is that a change to the concept has no single place to land.

One symbol per meaning inside a boundary

Within a bounded context, each term should resolve to exactly one type, and each type to exactly one term. That is a stronger constraint than it sounds, because natural language is full of near-synonyms, and developers reach for them unconsciously: user, account, member, profile. Each substitution looks harmless and each one creates a second name for the same idea, which then acquires its own methods and its own semantics.

A useful enforcement is mechanical rather than cultural. The glossary — a short table of term, definition, and the type that implements it — is checked in alongside the code, and a naming test asserts that the set of exported domain types matches the glossary entries. When someone adds ClientRecord, the test fails until either the type is removed or the glossary is amended, and amending the glossary forces the question of whether the domain really has two concepts.

The same discipline applies to field names and to the words used in error messages, log lines, and API payloads. An error that says “account not found” when the missing thing is a billing profile sends the reader to the wrong table. Error text is read under pressure, which makes it exactly the place where a sloppy noun does the most damage.

// Glossary: Invoice — a demand for payment issued against a BillingProfile.
// A BillingProfile is not an Account; an Account authenticates a person.
export type Invoice = {
  readonly invoiceNumber: InvoiceNumber;
  readonly billingProfileId: BillingProfileId;
  readonly issuedOn: LocalDate;
  readonly dueOn: LocalDate;
  readonly lines: readonly InvoiceLine[];
};

export function issueInvoice(
  profile: BillingProfile,
  lines: readonly InvoiceLine[],
  issuedOn: LocalDate,
): Invoice {
  if (lines.length === 0) throw new Error("an invoice requires at least one line");
  return {
    invoiceNumber: profile.nextInvoiceNumber(),
    billingProfileId: profile.id,
    issuedOn,
    dueOn: profile.paymentTerms.dueDateFrom(issuedOn),
    lines,
  };
}

The comment at the top is doing real work: it records the distinction that the types alone cannot state, and it does so in the same words the glossary uses. When the distinction changes, the comment and the glossary change together, and the code that depends on the distinction is visible.

Naming operations after the verbs the domain uses

Nouns get the attention, but verbs carry most of the behaviour. A method named updateStatus(newStatus) says nothing about the domain; ship(carrier, at), cancel(reason), and place() each name an event the business recognizes, and each one can be discussed with an expert without translation. The difference matters when the rules change: “orders cannot be cancelled after dispatch” is a sentence that maps directly onto a guard in cancel, whereas the same rule attached to updateStatus has to be reconstructed from a status table.

Verbs also expose modelling errors. When a method resists a domain verb — when the honest name would be process, handle, or applyChanges — the operation is usually doing several things at once, or the model has no concept for what is happening. Splitting it into two named operations often reveals a missing entity or a missing state.

The corresponding anti-pattern is the CRUD-shaped interface: create, read, update, delete on every type. These names describe storage, not domain behaviour, and they invite callers to construct states the domain forbids. A type that only exposes place, ship, and cancel cannot be put into an arbitrary state, which is the point made in Invariants and Illegal States.

Disagreement about a word is disagreement about the model

When two people use the same word for different things, the usual response is to pick one meaning and move on. That resolves the conversation and corrupts the model. The disagreement is evidence: either the concept has genuinely different meanings in different parts of the business, or one of the participants has an incomplete understanding.

The first case is the normal one, and it is what bounded contexts are for. “Customer” in the sales context — a prospect at a qualification stage — is not “customer” in the billing context — a party liable for invoices. Both models are correct within their boundaries, and forcing a single shared type produces a record with dozens of nullable fields and no invariant that holds. Bounded Contexts and Model Translation describes how to keep the two apart while still moving data between them.

The second case is a modelling gap, and it is cheapest to fix on a whiteboard. If the domain expert cannot say what distinguishes two uses of a term, the code will not be able to either. Writing the definition down and testing it against three concrete cases usually settles it in minutes; leaving it unresolved guarantees two synonyms in the codebase and an ambiguity that surfaces during the next incident.

When the code disagrees with the glossary

A glossary that is written once and never revisited becomes fiction within a release or two. Two habits keep it true. The first is to treat a rename as a model change: when a concept is renamed, the glossary entry, the type, the database column comment, the event name, and the API field all change in the same commit, because a half-renamed concept is worse than either name alone. The second is to record the old term in the glossary as a deprecated alias with its replacement, so that searches for the old word lead somewhere.

Legacy names are the hardest case. A table called tbl_cust_master from 2004 may hold the concept the glossary now calls BillingProfile, and renaming it is a migration with real risk. Keeping the glossary mapping explicit — physical name, logical name, owner — is usually enough, provided the mapping is consulted rather than guessed. What must not happen is a third name appearing in new code because neither the old nor the new one felt right.

There is a limit to how far the vocabulary can be purified. Some technical terms have no domain equivalent and should stay technical: id, version, createdAt. The test is whether an expert would recognize the word. If they would, it belongs in their vocabulary and should be spelled their way. If they would not, it is an implementation detail and should be named like one, so that the two categories stay distinguishable in the reader’s mind.

The measurable outcome is a codebase where a term can be traced from a sentence in a requirements discussion to a type, a method, and a column without a translation step. That traceability is what makes the model reviewable by the people who own the rules, and it is the only reason to spend effort on names at all.

References

  1. Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
  2. UbiquitousLanguage — Martin Fowler, bliki
  3. Software Requirements and Specifications — Michael Jackson, Addison-Wesley, 1995