EK9 in Depth

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.

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 @Lazy or a getBean call, 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.