Dependency Injection and Applications
Components in Volume 1 ended with a promise: "injecting a component so one implementation can be swapped for another" was a topic for the second volume. Build a Game, End to End made a second one: "You cannot unit-test a conversation", and the second volume would show how to replace a program's surroundings so that even the loop that talks to a person comes under test. This chapter keeps both, and they are the same mechanism. If you know Spring, Guice or .NET's built-in container, you know the shape: a class asks for a contract, and something outside it decides which implementation arrives. The difference is when that decision is checked. A Spring container discovers a missing bean, a cycle or a bad construction order when it starts up, by reflection. EK9 checks the wiring when the program compiles, and a program that is wired wrongly does not run at all.
A component, an application and a program
A component is declared as abstract, and each implementation is it. The consumer holds the abstract one, marked with !,
which means "ask the application for whatever was registered under this type". An application is the registry: one complete
set of choices. A program names the application it runs against. Here the same OrderService is wired to two notifiers:
#!ek9 defines module book.di.notifiers defines class OrderService notifier as Notifier! placeOrder() -> customer as String item as String notifier.notify(customer, `Your order for ${item} is confirmed`) default operator ? defines component Notifier as abstract notify() as abstract -> customer as String message as String EmailNotifier is Notifier override notify() -> customer as String message as String Stdout().println(`EMAIL to ${customer}: ${message}`) ConsoleNotifier is Notifier override notify() -> customer as String message as String Stdout().println(`CONSOLE [${customer}] ${message}`) defines application liveApp register EmailNotifier() as Notifier developmentApp register ConsoleNotifier() as Notifier defines program LiveShop() with application of liveApp orders <- OrderService() orders.placeOrder("alice@example.com", "a kettle") orders.placeOrder("bob@example.com", "two mugs") DevelopmentShop() with application of developmentApp orders <- OrderService() orders.placeOrder("alice@example.com", "a kettle") orders.placeOrder("bob@example.com", "two mugs")
LiveShop prints:
EMAIL to alice@example.com: Your order for a kettle is confirmed
EMAIL to bob@example.com: Your order for two mugs is confirmed
and DevelopmentShop prints:
CONSOLE [alice@example.com] Your order for a kettle is confirmed
CONSOLE [bob@example.com] Your order for two mugs is confirmed
OrderService never mentions a concrete notifier and nothing in it changed between the two runs; only the program's
with application of did. Three details are worth stating before the checks.
register EmailNotifier() as Notifierconstructs the implementation once, when the application starts, in the order the registrations are written; a registered component is a singleton for the life of the program. When the program ends the application releases them in the opposite order.- An injection is not a lookup. The compiler resolves
notifier as Notifier!to a load from a generated application object, so there is no reflection, no scanning, no name to mistype, and nothing configured outside the source. default operator ?is onOrderServicefor the usual reason: it is an aggregate with a field. The component types need none, because they have no fields.
The compiler will draw the wiring for you. ek9 -di reads the file and reports each application, what it registers and
which program uses it:
DI/AOP Analysis
═══════════════
Applications: 2
liveApp: 1 registrations, 0 aspects, all injections satisfied
developmentApp: 1 registrations, 0 aspects, all injections satisfied
Programs: 2
LiveShop -> liveApp (OK)
DevelopmentShop -> developmentApp (OK)
Totals: 2 registrations, 1 injection sites, 0 aspect proxies
Status: All injections satisfied, construction order valid
"Construction order valid" is the second of three checks the compiler runs over every application, and they are the point of the chapter.
What the compiler checks before anything runs
Spring's three classic start-up failures are a missing bean, a circular dependency and a bean created before the bean it needs. Each is a compile error here, and each is found by following what the program can actually reach, through the call graph, not by looking at declarations alone.
A missing registration. Add an audit log to the order service and forget to register it:
OrderService
notifier as Notifier!
audit as AuditLog!
defines application shopApp register EmailNotifier() as Notifier
EK9 : Error : E08210: 'Shop' on line 44 position 4: program 'Shop' reaches the injection of 'AuditLog' (first in 'OrderService'), not registered in application 'shopApp': program uses injection but application has no registration for the required component type
See: ek9 -h E08210
EK9 : Error : E08210: 'audit' on line 8 position 6: injection of 'AuditLog' in 'OrderService' is reached from program 'Shop' but is not registered in application 'shopApp', add a register statement: program uses injection but application has no registration for the required component type
See: ek9 -h E08210
The same fault is reported twice, once at the program that reaches the injection and once at the injection itself, because
either end may be the one you are looking at. The program does not mention AuditLog; it constructs OrderService, and
OrderService does. A program that has injections but is bound to no application at all is a different code, E08220.
A cycle. Let the order service need billing, and billing need the order service:
OrderService is Orders billing as Billing! override place() -> item as String billing.charge(item) default operator ? BillingService is Billing orders as Orders! override charge() -> item as String orders.place(item) default operator ?
EK9 : Error : E08190: 'OrderService' on line 14 position 4: OrderService -> BillingService -> OrderService circular dependency detected in component injection chain
See: ek9 -h E08190
A registration out of order. Registrations are constructed in the order they are written, so a component that injects another must be registered below it:
defines application shopApp register OrderService() as Orders register EmailNotifier() as Notifier
EK9 : Error : E08200: 'register' on line 31 position 6: 'OrderService' requires 'Notifier' which is not yet registered, reorder registrations: component used before registration in application block
See: ek9 -h E08200
EK9 : Error : E08200: 'notifier' on line 20 position 6: injection of 'Notifier' not available when 'OrderService' is used, reorder registrations: component used before registration in application block
See: ek9 -h E08200
"Out of order" is wider than the injected fields of the component. An injection is loaded from the application at the moment
its declaration runs, so anything that runs while a registration is being made counts: the constructor, the property
initialisers, the functions and classes they call, the constructor of an aspect (Aspects), even a call in the arguments of the
register line. The compiler follows all of them and rejects the registration if any reaches an injection of a type registered
later.
These are the other tools' mechanisms for the first of those faults, the circular dependency:
| Container | When a circular dependency is found |
|---|---|
| Spring | at run time, when the context initialises (BeanCurrentlyInCreationException); some cycles are masked by lazy proxies or setter injection |
.NET Microsoft.Extensions.DependencyInjection |
at run time, when the service is first resolved (InvalidOperationException) |
| Go Wire | at code-generation time, when it refuses to generate the injector |
| EK9 | at compile time, in the compiler itself; no lazy proxy and no setter to defer it |
Go Wire is close to EK9, because it also refuses before the program runs. The difference is that EK9 has no separate generation step to run, and no escape hatch such as a lazy proxy that moves the cycle somewhere a test might not reach.
A cycle is reported once, at the member registered first, and the members of the cycle get no ordering error as well: no order could satisfy them, so "reorder your registrations" would be advice that cannot be followed. A component that merely sits outside the cycle and is registered too early still gets its E08200.
A design note. A cycle is only half of it. In Spring a dependency that nothing provides is found when the application context starts, as an
UnsatisfiedDependencyException- or, behind@Lazyor agetBeancall, only when that path first runs, which may be in production. I wanted both faults to be compile errors. An injection the program can reach with no registration is E08210, one registered too late is E08200 and a cycle is E08190, so by the time a program compiles, the compiler has checked that every injection it can reach is registered, in an order that works. - Steve
Only an abstract component can be injected
You might expect out as Stdout! to work, since writing to the console is the first thing a test wants to replace. It does
not:
ConsoleGreeter is Greeter override greet() -> name as String out as Stdout! out.println(`Hello ${name}`)
EK9 : Error : E08160: 'out' on line 13 position 8: is not a component but a 'CLASS': dependency injection is not allowed
See: ek9 -h E08160
Only an abstract component is injectable. A concrete one is E08150 (notifier as EmailNotifier! asks for an implementation,
not a contract), and anything that is not a component is E08160: Stdout, Stdin and OS are classes, and Clock is a
trait. The rule is deliberate; the compiler's own help puts it as "ONLY components can be injected -- NOT classes", to keep
injection from becoming a way to reach any object from anywhere. What it costs is a layer: to put the keyboard or the
random-number source under a test you wrap it in a small component of your own, a contract of one or two methods and one
production implementation that holds the real thing. That layer is exactly what the next section builds, and it turns out to
be what you wanted anyway.
The seam Volume 1 promised
Build a Game, End to End ended with a guessing game whose loop could not be tested, because the loop needs a person at a
keyboard and a random secret from the operating system. Those two are the program's surroundings. Make each a contract:
SecretSource says "give me a secret", Player says "give me the next guess". The production implementations are the thin part
that talks; the game itself, GuessGame, depends only on the contracts.
#!ek9 defines module book.di.seam defines constant HIGHEST <- 100 MAX_TRIES <- 7 defines class GuessGame secrets as SecretSource! player as Player! play() <- rtn as String? secret <- secrets.secret() if not onTheBoard(secret) throw Exception(`the secret ${secret} is not from 1 to ${HIGHEST}`) tries <- 0 solved <- false while triesLeft(tries) and not solved tries++ if guess <- player.nextGuess() solved := guess == secret Stdout().println(hint(secret, guess)) rtn: verdict(solved, tries) default operator ? defines function hint() as pure -> secret as Integer guess as Integer <- rtn as String: String() order <- guess <=> secret rtn: order < 0 <- "higher" : order > 0 <- "lower" : "correct" onTheBoard() as pure -> secret as Integer <- rtn as Boolean: secret >= 1 and secret <= HIGHEST triesLeft() as pure -> tries as Integer <- rtn as Boolean: tries < MAX_TRIES verdict() as pure -> solved as Boolean tries as Integer <- rtn as String: String() rtn: solved <- `solved in ${tries} tries` : `not solved in ${tries} tries` defines component SecretSource as abstract secret() as abstract <- rtn as Integer? Player as abstract nextGuess() as abstract <- rtn as Integer? RandomSecret is SecretSource override secret() <- rtn as Integer: OS().random(1, HIGHEST) KeyboardPlayer is Player override nextGuess() <- rtn as Integer: Integer(Stdin().next()) defines application liveGame register RandomSecret() as SecretSource register KeyboardPlayer() as Player defines program Play() with application of liveGame game <- GuessGame() Stdout().println(game.play())
RandomSecret calls the same OS().random(1, HIGHEST) Volume 1 used, and KeyboardPlayer wraps Stdin, which is the layer the
previous section said you would write. A test needs no mocking framework, because a stand-in is another component. It
lives in the project's dev/ folder, beside the tests, and it is wired by a test application:
defines module book.di.seam.tests references book.di.seam::GuessGame book.di.seam::HIGHEST book.di.seam::hint book.di.seam::Player book.di.seam::RandomSecret book.di.seam::SecretSource book.di.seam::triesLeft book.di.seam::verdict defines component FixedSecret is SecretSource override secret() <- rtn as Integer: 37 ScriptedPlayer is Player guesses as Iterator of Integer? ScriptedPlayer() script <- [50, 25, 37] guesses: script.iterator() override nextGuess() <- rtn as Integer: Integer() if guesses.hasNext() rtn: guesses.next() default operator ? HopelessPlayer is Player override nextGuess() <- rtn as Integer: 1 OffTheBoardSecret is SecretSource override secret() <- rtn as Integer: 0 defines application scriptedGame register FixedSecret() as SecretSource register ScriptedPlayer() as Player hopelessGame register FixedSecret() as SecretSource register HopelessPlayer() as Player brokenGame register OffTheBoardSecret() as SecretSource register HopelessPlayer() as Player
FixedSecret is the pretend "random" that "always returns the same number" and ScriptedPlayer is "a pretend player who types a
fixed script", Volume 1's own words, and each is a few lines of the language you already have. The test applications are checked
exactly as liveGame is: leave the Player out of hopelessGame and the test does not compile (E08210). A test chooses its
application the way a program does:
defines program @Test ThirdGuessWins() with application of scriptedGame game <- GuessGame() assert game.play() == "solved in 3 tries" @Test RunsOutOfTries() with application of hopelessGame game <- GuessGame() assert game.play() == "not solved in 7 tries" @Test ASecretOffTheBoardIsRefused() with application of brokenGame game <- GuessGame() assertThrows (Exception, game.play()) @Test HintsPointTheRightWay() assert hint(37, 50) == "lower" assert hint(37, 25) == "higher" assert hint(37, 37) == "correct" @Test VerdictCountsTheTries() assert verdict(true, 3) == "solved in 3 tries" assert verdict(false, 7) == "not solved in 7 tries" @Test TheLastTryIsTheSeventh() assert triesLeft(6) assert not triesLeft(7) @Test LiveSecretStaysOnTheBoard() secret <- RandomSecret().secret() assert secret >= 1 and secret <= HIGHEST assert RandomSecret()? @Test GameIsSet() with application of scriptedGame assert GuessGame()?
The loop that Volume 1 called untestable now runs under a script. ek9 -t with the coverage summary gives:
ℹ️ Found 8 tests:
8 assert (unit tests with assertions)
Executing 8 tests...
✅ PASS book.di.seam.tests::ASecretOffTheBoardIsRefused [Assert] (7ms)
✅ PASS book.di.seam.tests::GameIsSet [Assert] (8ms)
✅ PASS book.di.seam.tests::HintsPointTheRightWay [Assert] (33ms)
✅ PASS book.di.seam.tests::LiveSecretStaysOnTheBoard [Assert] (6ms)
✅ PASS book.di.seam.tests::RunsOutOfTries [Assert] (9ms)
✅ PASS book.di.seam.tests::TheLastTryIsTheSeventh [Assert] (3ms)
✅ PASS book.di.seam.tests::ThirdGuessWins [Assert] (26ms)
✅ PASS book.di.seam.tests::VerdictCountsTheTries [Assert] (7ms)
Summary: 8 passed, 0 failed (8 total)
Types: 8 assert
Duration: 103ms
📊 Coverage Summary
⚠️ Methods: 66.7% (10/15)
✅ Lines: 80.9% (38/47)
✅ Branches: 100.0% (7/7)
✅ Overall: 81.13% (43/53 probes hit)
📦 By Module:
✅ book.di.seam: 81.13% (43/53)
What is still uncovered is the five methods that talk to the world: KeyboardPlayer, the liveGame application and the Play
program. They hold no decisions, which is the thin part Volume 1 said would be left. The bar is real, though. With only the
first two tests, both of which pass, the same project ends E83001: Code coverage 66.0% is below required threshold 80% and
exits 12. Testing the Compiler Trusts is about what coverage measures and what it does not.
What it costs
The checks above are the benefit; here is the price, in the order you will meet it.
There is one application per program, and it is chosen when you compile. A program names its application in its
declaration; nothing swaps an implementation while the program runs, and nothing reads a configuration file to decide. If you
want a different wiring you write a different program or a different test, as DevelopmentShop and the three test
applications did. That is less flexible than a container that reads a profile from the environment at start-up, and it is the
reason every check in this chapter is possible: the compiler knows the whole graph because the graph cannot change.
An injected variable is fixed. It is never assigned, not even with :=?, and it is always set:
Shop() with application of shopApp notifier as Notifier! notifier := EmailNotifier() notifier.notify("hello")
EK9 : Error : E08170: ':=' on line 24 position 15: an injected component is never assigned, not even with ':=?' - for a fallback, assign to a new variable ('chosen <- supplied' then 'chosen :=? injected')
See: ek9 -h E08170
The habit of checking whether an injected value arrived, which you may bring from a container where an optional injection can be null, is refused for the same reason. E08210 rejects a program whose reachable injection has no registration, so the check could only ever be true:
Play() with application of fixedGame dice as Dice! if dice? Stdout().println($dice.roll())
EK9 : Error : E08266: '?' on line 23 position 13: 'dice?': an injected component is always set, so checking that it is set is always true - use the injected value directly; for a fallback, use a new variable ('chosen <- supplied' then 'chosen :=? injected')
See: ek9 -h E08266
Wrapping the built-ins is your job. Stdout, Stdin, OS and Clock are not components, so the layer between them and your
logic is code you write, as KeyboardPlayer was. The benefit is a contract named for what the program needs rather than for
what the platform offers, which is the better design in any language; the cost is that you must write it before the first test.
A component may inject only four others. Five ! fields in one class or component is E11040:
EK9 : Error : E11040: 'OrderService' on line 27 position 4: has 5 injected fields (limit 4) - use a facade component to group dependencies: too many injected fields - use a facade component to group dependencies
See: ek9 -h E11040
The compiler's help says the limit of four is EK9's choice, and cites Clean Code on long argument lists and Seemann's "constructor over-injection". It also caps an application at twelve registrations (E11042) and a single callable at four injected types (E11043). The fix is the one the message names: group the collaborators behind a facade component, which is a design improvement the limit forces. You may disagree with four, but you cannot configure it away.
What the cost buys is a program whose wiring is a fact the compiler has already checked, whose test doubles are ordinary components, and which needs neither a container nor a mocking library. Aspects, next, hangs behaviour on the registration line without touching either the component or its consumers. A project too large for one file moves its contracts and implementations into modules; Organising a Growing Codebase sets out the layout.