Chapter 10 · Part IV

Languages for the Domain

When rules multiply past the point of writing each by hand, stop writing rules and design a vocabulary in which rules are written. A domain language turns Part II's ubiquitous language into something the machine can run.

Chapter 9 made the pricing engine open: a new rule is a new handler in a table, added without editing the engine. But watch what happens as the rules pile up. "Ten percent off." "Ten percent off, but only for loyalty members." "Ten percent off for loyalty members, capped at twenty dollars, never below the floor price, and not combinable with the bundle deal." Each is a handler, and each handler is a small program someone must write, test, and deploy. The openness is real, but the unit of extension is still code.

There is a further move, and it is one of the most powerful in the book. Instead of writing a program for each rule, design a small language whose words are the domain's own concepts — percent off, when, capped at, never below — and let rules be written as sentences in that language. This is the ubiquitous-language instinct of Part II, the discipline of naming domain concepts precisely, followed all the way to its conclusion: the names become executable, and the domain gets a notation of its own.

10.1 From Handlers to a Language

The shift is from writing behaviors to writing them down. A domain-specific language has the same two parts as any combinator system (Chapter 7): a set of terms — the primitive words — and combining forms that assemble terms into larger terms. The difference is one of intent. Here the terms are chosen to read as the domain reads, so that a pricing rule expressed in the language looks like a description of a pricing rule, not like the code that implements one.

We build it embedded first — as an algebra of values in Nex itself, no parser, no new syntax. A pricing rule becomes a value of a type Price_Expr: an expression that, given an order, evaluates to a price. The interpreter is a single method, eval, that every term implements.

-- The language: any pricing expression can be evaluated against an order.
deferred class Price_Expr
  feature
    eval(order: Placed): Money do end     -- each term overrides
end

As with Spec[T] and Handler, the interface is deferred and open — new terms are new subclasses, and the vocabulary grows without editing what exists.

10.2 The Words of the Language

Each term is a small class, and the combining terms hold sub-expressions — closure again, so expressions nest to any depth. The base term is the order's own total; the rest transform it.

class Base inherit Price_Expr
  create make() do end
  feature
    eval(order: Placed): Money do result := order.total end
end

class Percent_Off inherit Price_Expr
  create make(pct: Integer, inner: Price_Expr) do percent := pct  base := inner end
  feature
    percent: Integer
    base: Price_Expr
    eval(order: Placed): Money
      require
        valid_percent: percent >= 0 and percent <= 100
      do
        result := base.eval(order).less_percent(percent)
      ensure
        no_increase: result <= base.eval(order)      -- a discount never raises the price
      end
end

The conditional term is where the language pays a dividend from earlier chapters: its condition is a Spec[Placed] — the very specification algebra of Chapter 7. The two small languages compose, one supplying the questions and the other the prices.

class When inherit Price_Expr
  create make(s: Spec[Placed], t: Price_Expr, e: Price_Expr)
    do condition := s  yes := t  no := e end
  feature
    condition: Spec[Placed]
    yes: Price_Expr
    no: Price_Expr
    eval(order: Placed): Money
      do
        if condition.holds(order) then
          result := yes.eval(order)
        else
          result := no.eval(order)
        end
      end
end

-- "Never below the order's floor" — the membrane invariant, as a term.
class Floored inherit Price_Expr
  create make(inner: Price_Expr) do base := inner end
  feature
    base: Price_Expr
    eval(order: Placed): Money
      do
        let p: Money := base.eval(order)
        if p < order.floor_price then
          result := order.floor_price
        else
          result := p
        end
      ensure
        respects_floor: result >= order.floor_price
      end
end

Now a rule of real complexity is written as one nested expression — and it reads, top to bottom, like the sentence a pricing manager would speak:

-- "10% off for loyalty members, otherwise full price — but never below the floor."
let holiday_rule: Price_Expr :=
  create Floored.make(
    create When.make(
      create Loyalty_Member.make(),                        -- a Spec[Placed]
      create Percent_Off.make(10, create Base.make()),
      create Base.make()))

let quote: Money := holiday_rule.eval(order)

10.3 A Rule Is Now Data

The decisive gain is not that the rule is shorter. It is that holiday_rule is a value — a tree of ordinary objects — and not a compiled procedure. Everything you can do with data, you can now do with a pricing rule.

You can store it: serialize the tree to the database and load next quarter's promotions without a deploy. You can inspect it: walk the expression to show a manager, in plain words, exactly what a rule will do before it goes live. You can transform it: an optimizer that folds a Percent_Off of zero into its base, or a static check that flags a rule which could ever exceed a legal discount cap — both are just tree-walks over the same value. You can generate it: a rule-builder UI that emits Price_Expr trees, letting non-programmers author pricing that runs in the same engine as hand-written rules. A behavior locked inside a procedure can do none of this; a behavior expressed as data can do all of it. That is the whole reason to prefer a language over a pile of handlers.

This is also where the interpreter meets Chapter 9's table. In the embedded form above, the terms are Nex classes and the compiler still type-checks every expression you write in host code. In an external form — rules authored as text or JSON by people who are not programmers — the term names arrive as strings, and mapping "percent_off" to the Percent_Off constructor is exactly the data-directed dispatch of §9.2: a registry from term-name to builder, open to new words. The two forms are the same tree reached by two roads, one checked by the compiler and one that must be checked another way.

10.4 Contracts Guard the Sentences

A language is a wider opening than a table, and it lets in a new kind of author. Handlers were written by programmers; rules in a domain language are written by pricing managers, loaded from configuration, assembled by a UI. The values flowing through the interpreter are now, in the fullest sense, untrusted — and the type checker, which never saw the external ones at all, is more silent than ever. So the interpreter must carry its guarantees itself.

It does so in two layers. Each term states its own contract: Percent_Off requires a percentage in range and ensures it never raises the price; Floored ensures its result respects the order's floor. Because these contracts live on eval, they hold no matter how the term was assembled — hand-written, deserialized, or emitted by a builder — and no matter how deeply it is nested. A malformed sentence in the language fails at the term that is malformed, named and located, rather than producing a quietly wrong price.

And the whole interpreter sits behind the same membrane as everything on the open edge. Whatever tree it evaluates, the price it hands back crosses into the constrained core only through the engine's postcondition from §9.5 — non-negative, right currency, and, via a term like Floored, above the floor. The language is free to grow new words indefinitely; the core still accepts their output only on its stated terms. Openness in the vocabulary, constraint at the boundary: the book's shape, one level up.

10.5 How Far to Open the Language

Embedded and external DSLs sit at different points on the openness scale, and choosing between them is a judgment the book will return to. An embedded language is cheaper and safer: no parser, and the host compiler checks every rule you write in code. Its ceiling is that only programmers can write rules, and only at build time. An external language — its own syntax, parsed from text — is more work and gives up the compiler entirely, but it opens authorship to the whole business and to runtime. The more the vocabulary must grow in hands that are not the programmers', the further toward external you go, and the more of the guarding shifts from types to parsing (Chapter 4) and contracts.

Most systems want a point in between, and reach it the way this chapter did: an embedded core algebra of terms, with an external front that parses untrusted text into that same validated tree. Parse once at the boundary; evaluate a trusted expression thereafter. The domain gets its language; the core keeps its guarantees.

10.6 Toward Evaluators You Can Extend

We now have a domain language whose sentences are data and whose interpreter is a method that walks them. Its vocabulary is open — new terms extend it — but the interpreter itself is still a fixed thing: eval is defined once, and the set of operations the language supports is whatever the terms implement. The rules can grow; the evaluator cannot, not without editing it.

The last step of the frontier removes even that limit. What if the interpreter's own primitives could be added at runtime — if the language could be taught new fundamental operations, not just new combinations of old ones, while it is running? That is an evaluator you can extend, the logical endpoint of generic operations and the subject of the next chapter. It is the most open construction in the book so far, and, unsurprisingly, the one that leans hardest of all on contracts to stay reliable.