Everything in Parts I through III stayed inside the type system. Even the flexible parts — combinators, layers — were flexible only in ways the compiler could still check, because every new piece had to fit an interface declared in advance. That is the wall Part III ended at, and it is a real wall. Some growth cannot be foreseen well enough to name an interface up front, and for that growth the type system is not a help but an obstacle.
Part IV is where we open the frontier. We do it carefully, and we do it knowing exactly what we are giving up — because the reader has now spent six chapters seeing what safety looks like, so stepping outside it reads as a considered trade, not a default. The first move is the foundational one: generic operations, an operation whose behavior for a given kind of argument is looked up at runtime and can be extended by anyone, without editing the operation or the argument's type.
9.1 The Growth You Cannot Foresee
The running system has been waiting for this. Its pricing engine is supposed to be the open edge — "the rules that decide what an order costs will change forever, and they must be addable without reopening the core." So far we have deferred the how. Now confront it.
The pricing engine must support an open-ended set of rule kinds — percentage discounts, loyalty tiers, bundle deals, tax by jurisdiction, whatever marketing invents next quarter — and for each, compute a price. Try to build this with the tools of Part III and you hit the wall from two sides at once. If the engine matches over a sealed set of rule kinds, then every new rule means editing the engine and its exhaustive match — the sealed core's virtue is now a cage. If instead each rule carries its own price method, then teaching the system a new operation over all rules — say, "explain this rule to the customer" — means editing every rule class.
This is the classic tension: you want to add new kinds of data and new operations over them independently, without either forcing edits to the other. No single fixed interface resolves it, because the whole problem is that the set of kinds and the set of operations are both open. What you need is a way to associate an operation with a kind from the outside — a table that anyone can extend.
9.2 Dispatch Through a Table
A generic operation keeps its implementations in a registry, keyed by the kind of thing it is operating on, and dispatches at runtime by looking up the key. The operation itself knows nothing about any particular kind; it knows only how to find the handler and call it. Adding a kind is adding a row to the table.
Here is the pricing engine built that way. A Rule carries a string tag naming its kind; the engine holds a map from tag to handler; pricing is a lookup followed by a call.
-- A rule knows its own kind but not how it is priced.
deferred class Rule
feature kind(): String do end
end
-- A handler knows how to price one kind of rule.
deferred class Pricing_Handler
feature price(rule: Rule, order: Placed): Money do end
end
class Pricing_Engine
create make() do handlers := {} end -- an empty registry
feature
handlers: Map[String, Pricing_Handler]
register(kind: String, h: Pricing_Handler)
do
handlers.put(kind, h)
end
price(rule: Rule, order: Placed): Money
require
known_rule: handlers.contains_key(rule.kind) -- the type system can't promise this
do
result := handlers.get(rule.kind).price(rule, order)
ensure
non_negative: result >= Money.zero -- so a contract does
same_currency: result.currency = order.total.currency
end
end
The engine is now closed to modification and open to extension in the strongest sense we have seen. It has no branch per rule, no match to keep exhaustive, no knowledge of any specific rule at all. To teach it a new rule kind, you never touch this class.
9.3 Adding a Rule, Editing Nothing
A rule kind is a Rule subclass plus a Pricing_Handler for it, plus one register call. Watch where the type system goes quiet:
class Percent_Off inherit Rule
create make(p: Integer) do percent := p end
feature
percent: Integer
kind(): String do result := "percent_off" end
end
class Percent_Off_Handler inherit Pricing_Handler
create make() do end
feature
price(rule: Rule, order: Placed): Money
require
matches: rule.kind = "percent_off"
do
convert rule to r: Percent_Off -- recover the concrete type at runtime
result := order.total.less_percent(r.percent)
end
end
Registering it wires the kind to its handler — and a second, entirely new rule slots in the same way, with zero edits to Pricing_Engine, to Placed, or to any existing rule:
engine.register("percent_off", create Percent_Off_Handler.make())
-- Later, a rule nobody anticipated — added by extension, not surgery:
engine.register("loyalty_tier", create Loyalty_Handler.make())
let quoted: Money := engine.price(rule, order)
This is the payoff the running example was built to reach. The set of pricing rules is now genuinely open: a new rule is new code in a new file, and the engine, the order core, and every existing rule are untouched. Both axes of growth — new kinds, and new operations if we add more tables — are additive. The expression problem dissolves, because the association between kind and behavior lives in data, where anyone can add to it.
9.4 Where the Types Fall Silent
We paid for this, and it is important to be exact about the coin. Three things the compiler used to guarantee, it can no longer see:
- That a handler exists.
handlers.get(rule.kind)looks up a string key. Nothing at compile time promises the table has a row for this rule; a rule whose kind was never registered sails past the type checker and fails only at runtime. - That the handler matches the rule. The line
convert rule to r: Percent_Offis an unchecked downcast from the openRuletype to a specific one. The compiler cannot verify that the handler registered under"percent_off"actually receives aPercent_Off— the correspondence between the string tag and the class is invisible to it. - That the produced price is well-formed. Because the handler is called through a table, the compiler cannot follow the value it returns; a buggy handler could return a negative price, or one in the wrong currency, and no type would object.
This is not a language deficiency to route around; it is the deal. We deliberately traded a closed, statically-checked set of kinds for an open, runtime-dispatched one, and the loss of these three guarantees is the exact price of the openness. The question is not how to avoid paying it — you cannot, if you want the openness — but how to buy back what matters most. And the tool for that is the one the book has been holding in reserve since Chapter 2.
9.5 Contracts Take Over the Guarding
Look again at the code, now for its contracts rather than its dispatch. Every place the type system fell silent, a contract speaks in its place — and the three losses of §9.4 map one-to-one onto three guards.
The engine's precondition known_rule: handlers.contains_key(rule.kind) stands exactly where the compiler could no longer promise a handler exists. It turns "there is no handler" from a mysterious runtime lookup failure into a named, located contract violation at the boundary — the place a missing registration should be caught. Inside the handler, require matches: rule.kind = "percent_off" guards the downcast: the unsafe convert is made safe because the precondition establishes, in the handler's own terms, that the rule really is the kind this handler prices. And the engine's postconditions — non_negative and same_currency — guard the value on its way out, so that no matter which of the open-ended handlers produced the price, and no matter what bug it harbors, a malformed price cannot leave the engine.
Those postconditions are not new. They are the membrane from Chapter 2, finally doing its real work. Recall apply_quote, the core's boundary routine, whose precondition demanded a price be non-negative and in the order's currency before the core would accept it. The engine's postcondition is the matching promise on the other side of that seam: the open pricing edge guarantees exactly what the constrained core requires. Neither side sees inside the other; the contract is the whole of their agreement. This is the arrangement the first chapter sketched — constrain the core, open the edge, let contracts be the membrane between them — running for real, with a genuinely open edge on one side and a genuinely closed core on the other.
9.6 A Considered Trade
It would be a misreading of this chapter to conclude that generic operations are simply better than the typed composition of Part III. They are not better; they are different, and more costly. You give up compile-time proof that every case is handled, that every value is well-formed, that a refactor did not orphan a handler. In exchange you gain extension by strangers — the ability for a rule written next year, by someone who has never seen the engine, to slot in without a single edit to existing code. That trade is worth making exactly when the set of kinds is genuinely open and genuinely unpredictable, and worth refusing when it is not. A payment status that will only ever be one of four values should stay a sealed type; a pricing rule catalog that grows with the business wants a table. Knowing which is which is the judgment Part VI is devoted to.
What matters here is that the openness is bounded. We did not abandon guarantees; we relocated them from the type system to contracts, and concentrated them at the membrane where the open edge meets the closed core. The frontier is open, but it is fenced.
Generic operations are the foundation of everything left in Part IV. Once an operation's behaviors live in a table that grows at runtime, you are a short step from letting the vocabulary itself grow — from giving the domain its own language whose terms can be added on the fly (Chapter 10), and then from building evaluators whose primitives extend at runtime (Chapter 11). Each is a wider opening of the same door, and each leans harder on contracts to stay reliable. We have crossed the line; now we build on the far side of it.