DI, IOC and Containers

Encodo keeps the SOLID principles in mind when designing software.

DI & IOC

We implement the Inversion of Control [I] pattern with the dependency-injection pattern (D) to allow for a large amount of flexibility in how an application is composed. We've applied this principle throughout the Quino framework and use it in our products as well.

What does this mean? It means that the product or framework doesn't make any decisions about which exact components to use. Instead, it indicates the API Surface (interface) that it expects in the form of injected components. That is, the responsibility for deciding which component to use lies not with the lowest level of the software stack, but with the highest level.

This inversion means that the application entry point configures the object graph (i.e. which objects will be used). That makes it much easier to isolate and test individual components, especially where those components would depend on native- or web-only functionality in production.

See the How do I DI? presentation from February 2018 for more information.

Components

An application is a graph of components, each with one responsibility (S) and zero or more dependencies, injected via the constructor. Components are composed with other components to build higher-level functionality (O). They are also unaware of the other components' implementations and can be replaced with other implementations (L).

Components make software flexible:

  • Products can replace any component without changing anything else
  • Products can inject any component without pulling in more functionality than needed

Declaration & Implementation

Components have a very clear purpose (S) indicated through an interface. In most cases, we use an actual "interface" language construct to clearly define the API surface and to not limit a product in its implementation (e.g. with an abstract base class).

  • Prefer composition to inheritance, exposing clear dependencies
  • Reference dependencies via interface (or type or protocol, depending on language) with as small a surface area as possible
  • Obtain dependencies through injection, preferably in the constructor

Most components have a single method, amounting to a functional interface and allowing composition with lambdas. While TypeScript has this feature (as does Java), C# does not. We end up defining a lot of single-method classes that implement a single interface. It's more code than we'd like, but it's purely structural syntax and doesn't introduce additional complexity.

See the Interfaces, base classes and virtual methods in the Quino conceptual documentation for more information and on and examples of patterns that we use.

Containers

Although it's possible for applications to manually create an object graph (the composition root), we prefer to use an IOC Container.

The container provides two services:

  • Registration: Applications declare the object or type and the lifetime (generally singleton) to use for interfaces
  • Requests: Applications request objects, which the IOC creates—injecting other registered objects, as necessary—or retrieves, depending on lifetime. A container can create transient objects even for unregistered types.

The container introduces the following restriction:

  • A concrete type may have many constructors, but only one may be public

The lifetime of an application is as follows:

  • Collection registrations in the IOC Container
  • Create composition root with the IOC Container
  • Apply method to composition root

See the Quino Application Configuration for more information about application lifecycle. The blog article Starting up an application, in detail is a bit older, but provides more detail on how Quino integrates the IOC into the startup.

In the long example below, we will first look at how composition even without a container is very powerful. Then we'll look at how a container can improve on that.

Example

Although we generally use C# or TypeScript in our work, these examples were originally written to introduce Swift developers to an iOS framework that we wrote.

Step One: A limited robot simulator

Let's take a look at an example of an application that looks OK at first, but turns out not to be very flexible.

Note: The example is small, so some of the steps will feel like over-engineering. It's a good point, but the principles shown here apply just as well for larger systems.

The following example defines a simulator that can move a robot along a route, defined by movements. The robot starts at a given location and can travel at a fixed speed.

enum Direction
{
  case north
  case south
  case east
  case west
}

struct Movement
{
  let direction: Direction
  let distance: Int
}

struct Point
{
  var x: Int
  var y: Int
}

class FastRobot
{
  var speed = 2
  var location: Point = Point(x: 0, y: 0)
  let movements: [Movement] = [Movement(direction: .north, distance: 1)]

  func move()
  {
    for movement in movements
    {
      let distance = speed * movement.distance
      switch (movement.direction)
      {
      case .north:
        location.y += distance
      case .south:
        location.y -= distance
      case .east:
        location.x += distance
      case .west:
        location.x -= distance
      }
    }
  }
}

class Simulator
{
  func run()
  {
    FastRobot().move()
  }
}

As mentioned above, this implementation looks well-written, but what if we wanted to verify that the robot ended up at the right location? Let's try that below.

Step Two: Running the limited robot

Simulator().run()

// Now what?

It turns out that we can't test anything in this application. We can fix this by applying the patterns outlined in the first section.

Step Three: Decouple the robot from the simulator

First, let's tackle the Simulator interface:

class Simulator
{
  func run(robot: FastRobot)
  {
    robot.move()
  }
}

let robot = FastRobot()
Simulator().run(robot: robot)

XCTAssertEqual(robot.location.x, 0)
XCTAssertEqual(robot.location.y, 2)

Now we can test that the robot is working as expected.

The robot is still quite hard-coded, as is the simulator's relationship to the robot. The robot must be a FastRobot and it can only move along a fixed route.

Step Four: Reduce the robot "surface"

We'll first decouple the Simulator from a direct dependence on the FastRobot.

protocol IRobot
{
  func move()
}

class Robot : IRobot
{
  // As above
}

class Simulator
{
  func run(robot: IRobot)
  {
    robot.move()
  }
}

Now the simulator only knows about the protocol IRobot, which has a very small surface area. It's still too small to be very useful.

Step Five: Make the robot configurable

Instead of hard-coding everything, we can compose the robot out of parts. Examining the algorithm, we see three parts that could be externalized:

  • The robot's speed is currently fixed. We could make a component that is responsible for calculating the speed of the robot.
  • The robot's route is also fixed. We could make a component to represent the route as well.
  • Finally, the robot's initial position is also fixed. We could make that configurable as well.

Let's first externalize all of the hard-coded values out of the FastRobot into a generic Robot class.

class Robot : IRobot
{
  let speed: Int
  var location: Point
  let movements: [Movement]

  init(speed: Int, location: Point, movements: [Movement])
  {
    self.speed = speed
    self.location = location
    self.movements = movements
  }

  func move()
  {
    for movement in movements
    {
      let distance = speed * movement.distance
      switch (movement.direction)
      {
      case .north:
        location.y += distance
      case .south:
        location.y -= distance
      case .east:
        location.x += distance
      case .west:
        location.x -= distance
      }
    }
  }
}

Now we can create a Robot, injecting all of the initial conditions.

let origin = Point(x: 0, y: 0)
let route = [Movement(direction: .north, distance: 1)]
let robot = Robot(speed: 2, location: origin, movements: route)

Simulator().run(robot: robot)

XCTAssertEqual(robot.location.x, 0)
XCTAssertEqual(robot.location.y, 2)

The same assertions hold as before, but the Robot class is much more generalized. We can now test the robot's movement algorithm with various combinations of origin, speed and route.

At this point, we've made the robot and simulator composable and testable. Now we want to have a look at how we can separate the configuration from the usage.

Using a container to build objects

We're not nearly done, though. What does this all have to do with a service provider? That's where the inversion part comes in.

In the very first example, the Simulator was responsible for creating the robot. This made it impossible to test whether the robot did what it was supposed to do.

So we passed the robot in as a parameter to run(), making the caller responsible for creating the robot instead of the Simulator.

This is fine, as long as the caller is the top-level part of the program, responsible for composing the objects that will be used. However, what if the direct caller doesn't know how to do that? Or, put another way, what if the caller should not be doing that?

What if the caller is a button handler in a UI? Would we want the button handler—or the UI that contains it—to be responsible for constructing the robot or its initial conditions?

This is where the container comes in: we want to register all of the types and instances that we want to use in one place. This configuration can be retrieved at any later point without knowing any more than the interface that's required.

This takes us full circle to the original code, except, instead of creating the Simulator directly, we want to get it from a container, called a provider in the following examples.

let simulator = provider.resolve(ISimulator.self)

simulator.run()

let robot = provider.resolve(IRobot.self)

XCTAssertEqual(robot.location.x, 0)
XCTAssertEqual(robot.location.y, 2)

Note: For reasons of simplicity, we assume that all objects in the container are singletons.

Step Six: Configure the container

Let's take the configurable code above and translate it to a container. Here the registrar is the configurable part and the provider is the part that can be used to retrieve objects based on that configuration. The registrar is sometimes called the composition root.

Note: We use the syntax for the Swift IOC, but the examples are hopefully clear enough in their intent.

In the example below, we register singletons for each of the objects we want the container to be able to create, Point, Int, [Movement], IRobot and Simulator.

let registrar = ServiceRegistrar()
  .registerSingle(Int.class) { _ in 2 }
  .registerSingle(Point.class) { _ in Point(x: 0, y: 0) }
  .registerSingle([Movement].class) { _ in [Movement(direction: .north, distance: 1)] }
  .registerSingle(IRobot.class) { p in Robot(speed: p.resolve(Int.class), location: p.resolve(Point.class), movements: p.resolve([Movement].class))}
  .registerSingle(Simulator.class) {p in Simulator(p.resolve(IRobot.class))}

This is a decent start, but many of the registrations above have no semantic meaning, like Int and Point and [Movement]. For these, it's better to use higher-level abstractions.

Step Seven: using higher-level abstractions

We need to define three abstractions—called IOrigin, IRoute and IEngine—with implementations. The IRobot interface also needs to be redesigned to use them.

protocol IRoute
{
  var movements: [Movement] { get }
}

protocol IOrigin
{
  var point: Point { get }
}

protocol IEngine
{
  var speed: Int { get }
}

protocol ISimulator
{
  func run()
}

class Simulator : ISimulator
{
  var robot: IRobot

  init (_ robot: IRobot)
  {
    self.robot = robot
  }

  func run()
  {
    robot.move()
  }
}

struct StandardRoute : IRoute
{
  var movements: [Movement] = [Movement(direction: .north, distance: 1)]
}

struct StandardOrigin: IOrigin
{
  var point: Point = Point(x: 0, y: 0)
}

struct FastEngine : IEngine
{
  var speed: Int = 2
}

class Robot : IRobot
{
  var location: Point!
  let engine: IEngine
  let route: IRoute

  init(_ engine: IEngine, _ origin: IOrigin, _ route: IRoute)
  {
    self.engine = engine
    self.route = route

    location = origin.point
  }

  func move()
  {
    for movement in route.movements
    {
      let distance = engine.speed * movement.distance
      switch (movement.direction)
      {
      case .north:
        location.y += distance
      case .south:
        location.y -= distance
      case .east:
        location.x += distance
      case .west:
        location.x -= distance
      }
    }
  }
}

We've created concrete objects for our standard parameters. An added bonus of the improved semantics is that we can rewrite the init for IRobot so that it no longer expects argument labels—because the parameter are now clear without further explanation.

Now we can take another crack at the configuration using these new types. This time, we'll define an extension of the IServiceRegistrar that we can use again below.

extension IServiceRegistrar
{
  func useSimulator() -> IServiceRegistrar
  {
    return self
      .registerSingle(IEngine.class) { _ in FastEngine() }
      .registerSingle(IOrigin.class) { _ in StandardOrigin() }
      .registerSingle(IRoute.class) { _ in StandardRoute() }
      .registerSingle(IRobot.class) { p in Robot(engine: p.resolve(IEngine.class), p.resolve(IOrigin.class), p.resolve(IRoute.class))}
      .registerSingle(ISimulator.class) {p in Simulator(p.resolve(IRobot.class))}
  }
}

We've now configured a system that knows how to create our simulator along with all of its dependencies. You can see that if the ISimulator type is resolved from the container, it will

  • create a Simulator, which
  • resolves the IRobot, which
  • resolves the IEngine, IOrigin and IRoute

Step Eight: Changing the speed

An application can now change the speed of the robot without knowing anything else about the simulator, simply by changing the IEngine that's used.

class SlowEngine : IEngine
{
  var speed: Int = 1
}

let provider = ServiceRegistrar()
  .useSimulator()
  .registerSingle(IEngine.class) { _ in SlowEngine() }
  .commit()

As well, any location in the application can either use the IRobot or the ISimulator without having to know anything about how either of the concrete objects are constructed. The simulator might be much more complicated than the very simple one defined above. The robot might do much more when asked to move.

Step Nine: Using a factory

What if we wanted to let the robot decide how fast it is, depending on what kind of robot it is? Or what if we want to separate the speed from being fixed in the IEngine?

What we need is a way to create transient objects that require parameters that are not available in the provider. These are types like Int, String, etc., as we had in Step Six above.

The example below shows a very simple usage of the factory pattern. Instead of having a single IEngine for the whole application, we want to provide settings that the robot uses to get its engine.

The code below sketches the new types and shows how the robot would use them.

protocol IEngineFactory
{
  func createEngine(speed: Int)
}

protocol IRobotSettings
{
  var speed: Int
}

class Robot : IRobot
{
  init(_ engineFactory: IEngineFactory, _ settings: IRobotSettings, _ origin: IOrigin, _ route: IRoute)
  {
    self.engine = _engineFactory.createEngine(settings.speed)

    // ...
  }

You'll note that we didn't declare any new properties. The robot still just has an engine, but asks the factory to create it based on a speed, rather than having the provider inject its singleton.

The robot's speed can now be configured without replacing the entire implementation.

let simulator = provider.resolve(ISimulator.self)
let robot = provider.resolve(IRobot.self)
let settings = provider.resolve(IRobotSettings.self)

settings.speed = 10;

simulator.run()

XCTAssertEqual(robot.location.x, 0)
XCTAssertEqual(robot.location.y, 10)
updated on 5/2/2019