Ubiquitous language is not a glossary: why your accounting model speaks the wrong language


The hard problems in software are, at their core, problems of meaning. Not performance, not scalability, not which framework to use. Meaning. What does this concept actually mean, where do its boundaries end, what relationships does it maintain with other concepts. The philosophy of language has been working on this for over a century — Frege distinguished between sense and reference precisely because a single object can be named in ways that imply radically different understandings. In software, that distinction is paid for with bugs.

The name we give things matters

There is a permanent temptation in development teams: treating language as a cosmetic detail. We assign generic names, use abbreviations, map domain concepts to technical structures without asking whether the translation preserves meaning. But naming is not labeling — it is constituting. The name you give a class determines how you think about it, what operations you consider valid, what invariants you expect it to uphold.

Eric Evans said it over twenty years ago, and it bears repeating. Ubiquitous language is not a glossary. It is not a document living in Confluence that nobody reads. It is the way the team thinks, discusses, and models the problem. In philosophical terms, it is the medium through which the domain becomes intelligible. Wittgenstein was right: the limits of your language are the limits of your world. If your domain vocabulary is poor, your model will be poor — not because you lack technical talent, but because you do not have the words to think with precision.

When the code says one thing and the business says another, the bug is not in the code. It is in the conversation they never had.

When a developer writes Transaction and the accountant says “journal entry,” they are not talking about the same thing. This is not a translation problem — each term carries a different sense, a different web of implications. Transaction evokes something generic, interchangeable, without rules of its own. “Journal entry” evokes double-entry bookkeeping, balance, an invariant that is not negotiable. That fracture propagates: into tests, into documentation, into pull request conversations. The team ends up maintaining two mental models in parallel, and neither one is correct.

A concrete case: the journal entry

In an accounting system, the concept of a “journal entry” is not trivial. A journal entry is not a record. It is not a row in a table. It is an atomic operation that must satisfy strict invariants: the sum of debits must equal the sum of credits. Always. Without exceptions.

If your model has a Transaction class with an amount: float field and a type: str that can be "debit" or "credit", the problem is already in place. That model does not express the invariant. It does not protect it. Money is a primitive that does not know its currency, the type is a string that enforces no rules, and the responsibility for maintaining balance falls on the code that uses the class, not on the class itself. And here a distinction that philosophy knows well appears — the difference between contingent truth and necessary truth. That a journal entry is balanced is not something that can happen — it is something that must happen for the very concept of “journal entry” to make sense. It is a constitutive truth condition, not an optional validation.

@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def plus(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise IncompatibleCurrencyError(self.currency, other.currency)
        return Money(self.amount + other.amount, self.currency)

    def is_zero(self) -> bool:
        return self.amount == Decimal("0")

    @classmethod
    def zero(cls, currency: str) -> "Money":
        return cls(Decimal("0"), currency)


class JournalEntry:
    def __init__(self, lines: list[EntryLine]) -> None:
        balance = reduce(
            lambda total, line: total.plus(line.amount),
            lines,
            Money.zero(lines[0].amount.currency),
        )
        if not balance.is_zero():
            raise UnbalancedEntryError(balance)

        self._lines = tuple(lines)

Notice what changed compared to the flat model. It is not just the language. It is the design. Money is not a float — it is an immutable Value Object that knows its currency and refuses to add euros to dollars. There is no type == "debit" as a string. Each EntryLine carries an amount with sign, and the invariant is expressed as what it is — the sum must be zero. The name JournalEntry is not a label — it is a declaration of intent. The invariant lives inside __init__. There is no way to create an unbalanced entry. The model protects itself. In Kripke’s terms, JournalEntry functions as a rigid designator: no matter what context you use it in, it always refers to the same concept with the same identity conditions. Transaction, on the other hand, is a vague term that shifts reference depending on who reads it.

The glossary is not enough

A glossary tells you that “asiento” translates to “journal entry.” Ubiquitous language tells you that a JournalEntry is an aggregate with invariants, that Money is an immutable Value Object that enforces currency and precision rules, that each EntryLine carries a signed amount, and that UnbalancedEntryError is a domain exception that signals a violation of business rules.

The difference is the same as between knowing the name of something and understanding how it works. Feynman explained it with birds: you can know the name of the bird in every language in the world and still know absolutely nothing about the bird.

How ubiquitous language is built

It is not decreed. It is not written in a document and distributed. It is built through conversation, iterating, fighting for the right names.

  • Listen to the domain. When an accountant says “journal entry,” do not translate it to “transaction.” Ask what rules it has, what can go wrong, what happens when it is violated.
  • Make the code speak. If you have to explain what a class does, the name is wrong. JournalEntry needs no explanation. Transaction needs a paragraph.
  • Fight for the names. Every time someone says “it is the same thing,” be suspicious. In DDD, synonyms are suspect. They almost always hide a different concept or a bounded context you did not see. Wittgenstein spoke of “language games”: contexts where the same words obey different rules. A bounded context IS a language game — and “customer” in sales and “customer” in support are not playing the same game.
  • Refactor the language before the code. If the team cannot explain the model in a single sentence, the model is wrong. It does not matter if the tests pass.

Semantic debt is the most expensive kind

You can refactor code. You can improve the architecture. But when the entire team misunderstands a domain concept, no refactoring will fix it without going back to the fundamental conversations.

The next time someone tells you “it is just a naming detail,” remind them that language does not describe the domain — it constitutes it. It is not a presentation layer over a reality that exists independently of how we name it. The model IS the language. And if the language lies, everything you build on top of it will be a lie too.

Philosophers of language have spent a century debating how words connect to the world. Software architects should pay them more attention. Because every time you name an aggregate, define a bounded context, or declare an invariant, you are doing ontology — whether you realize it or not.

Armando Zarate

Software craftsman. Writes about Domain-Driven Design, complex problem solving, and the power of language in software development.