Why use an IOC? (hint: testing)

Published by marco on

Inversion of Control Pattern

The IOC pattern is the [I] in SOLID. It stands for “Inversion of Control”.

In order to make good use of this pattern, an application should adhere to the following rules:

  • Prefers composition over inheritance, exposing clear dependencies
  • Refers to dependencies via interface or protocol types with as small a surface area as possible Obtains dependencies through injection, preferably in the constructor

Components built in this manner are agnostic in their implementation. They can be composed by an application as it sees fit.

Containers and Injection

Many projects will use an “IOC Container” that offers the following features for enforcing and benefiting from the pattern.

  • The container allows an application to register the class or object to use for a given interface
  • The application can also indicate whether the class or object should be considered a singleton
  • The container can create objects automatically, as long as all parameters to the object’s single constructor are of types registered with the container

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.

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’s 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.

Still 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 see how we could 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 objects and classes we want to use at the beginning of the application. 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. As well, the IRobot interface 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 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
      } 
    }
  } 
}

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.