Dependency Inversion In Practice

There have been fewer posts recently. Since I have a regular job now, it is harder to write posts without referring to work-specific topics - of which there are many interesting ones - which however I cannot cover due to NDAs.

When working as a software architect in a professional setting one quickly discovers that the primary means of writing "good" software is not making it fast (as beginners often believe), but making it testable. Performance is not contradictory to that. Good design thinks about testability first. Otherwise, the software might be fast - but fast software that crashes or provides invalid results is useless. Optimizing before a working prototype exists often leads to unnecessary optimizations. More than once have I asked a developer to stop worrying about the performance of a certain step of computation and pointed out that it might be executed once an hour or even less often, with the unoptimized invocation taking less than a millisecond.

Testable code

Dependency Inversion is one of the five SOLID principles. While it is not the only one required to obtain testable code, it is perhaps the most essential one. At time of writing, the Wikipedia article about DI was the only one of the SOLID articles that mentioning testing.

This post will focus on C++, however the principles are applicable to many object-oriented languages, although the difficulties encountered in other languages might have a different emphasis. Especially new languages, which had the opportunity to learn from C++' mistakes, have solved some of the problems presented here at least partially, making them less of an issue (I am thinking of Rust here).

Testable code enables writing tests of various granularity. These might be

  • Unit tests
  • Unit-integration tests
  • Component tests
  • Component-integration tests
  • Module tests
  • ...
  • Software tests.

Whether these granularities exist in your organization depends on your definition of units, components etc., which are far from standardized. Arguing about terminology is merely bikeshedding. In fact, what constitutes a unit is highly dependent on the industry. A processor designer probably thinks about a multiplication instruction differently than a game developer. One developer's unit test might be another developer's software test. In C++, if code allows for unit tests, all levels of testing of coarser granularity (component, module, sofware) are usually also possible, but not vice versa.

Unit testing

To make code unit-testable, it must be composed of units. To make things simple for this post, a unit is a piece of software that depends only on language intrinsics and the standard library. In particular, it does not depend on other units, any coarser software components or operating-system- or platform-specifics. This of course implies that a lot of software cannot be composed of units, due to OS- or platform-specific behavior. These parts of the software should be implemented in components that are kept as simple as possible because they cannot be unit tested.

A unit should implement no more than a small number of inherently dependent behaviors. A good indication of a well-designed unit is code coverage. A tester, supplied with the unit's public interface and behaviors should be able to achieve 100% code coverage without knowing the implementation details.

So far, this leaves us plenty of freedom to implement our units.

Testing components

Most software consists of more than a single software unit. Parts of the sofware depend on the functionality that units provide, and other parts depend on these components. For simplicity, this post calls everything a component that does not fulfil the definition of a software unit.

The dependency inversion principle says that

Components as well as units must depend on abstractions only.

This is easier said than done. In most software, both units and components depend on implementations rather than abstractions. This makes testing of anything but units extremely complicated since hard-coded dependencies on implementations cannot be mocked during testing. To illustrate this, in the following example a software component called WindowManager stores Windows. It allows the user to interact with Window objects using a WindowHandle.


/* window_manager.hpp */
#include "window.hpp"
#include "window_handle.hpp"

#include <vector>

class WindowManager final {
public:
    WindowHandle open();

private:
    std::vector<Window> m_windows;
};

In order to compile this code, the headers window.hpp and window_handle.hpp must be available. For sake of simplicity, these are assumed to be units (which, in practice, is unlikely, especially for the WindowHandle). Here, an important observation must be made: a header is not an abstraction in the sense that is required by the DI principle. An abstraction in the sense of DI requires that the implementation can be substituted for by a different implementation that satisfies the abstraction.

Since neither Window nor WindowHandle are abstract classes (obvious from the fact that they are used as values), they cannot be substituted by a different implementation. The dependency graph looks as follows:

        +---------------+
        | WindowManager |
        +---------------+
           |        |
      +----+        +------+
      |                    |
      v                    v
 +--------+        +--------------+
 | Window |        | WindowHandle |
 +--------+        +--------------+

When testing software components, the lowest level of test should be a unit test. This seems counter-intuitive - after all the piece of software is a component, not a unit. There are compelling arguments for this, though:

  • Unit tests have by far the lowest complexity.
  • They are also often required to test behavior that might otherwise be difficult or even impossible to reach.

Not being able to unit-test each software component is in my experience one of the main reasons why people think that writing tests is hard and that tests are brittle and require a lot of maintenance. None of this is true when everything is unit tested.

To unit test a component, all of its dependencies must be mocked.

To achieve mockability for all dependencies, two things must be observed:

  • A component may only ever handle abstractions. This means that both in its interface (i.e. the abstraction of the component itself) as well as in its implementation, only abstract classes may be used.
  • All constructors must be replaced by factories. This detail is often overlooked and one of the main reasons why components end up depending on implementations after all.

The complexity of these steps is sometimes misunderstood. In practice, using factories instead of constructors is quite easy. Handling only abstractions is however highly inconvenient. This inconvenience is exacerbated by the fact that modern C++ prefers value semantics over pointers and references. A good discussion on this topic can be found here.

Depending on abstractions

In C++, an abstraction usually takes the form of a pure abstract class, i.e. a class with virtual methods only. In our example, we would like the dependency tree to look as follows:

 +------------+      +------------------+
 | WindowBase |      | WindowHandleBase |
 +------------+      +------------------+
   ^       ^           ^      ^
   |       |           |      |
   |       |           |      |
   |     +---------------+    |
   |     | WindowManager |    |
   |     +---------------+    |
   |                          |
 +--------+           +--------------+
 | Window |           | WindowHandle |
 +--------+           +--------------+

Introducing abstractions

This can be achieved by creating pure abstract classes WindowBase and WindowHandleBase and deriving from them. By including only window_base.hpp and window_handle_base.hpp, WindowManager is independent from any other unit or component. Neither do Window or WindowHandle depend on anything else. This is by the way also the step where any dependencies between Window and WindowHandle can be resolved.

But wait! How do we instantiate any of these classes? Pure abstract classes cannot be constructed and there is no way to reach the constructors of Window or WindowHandle in WindowManager.

This is where factories come into play.

Factories

A factory is a software component that produces instances of other software components. Since the return value of a factory invocation can be a pointer or reference, the abstraction of the factory does not need to depend on the implementation of the components it produces. Contrary to other components, its implementation must depend on the implementation of the components it produces: it is the only piece of code in the architecture that is allowed to call a constructor. It is inherently impossible to unit-test a factory. Its implementation should therefore be minimal. All error-checking and other logic must be implemented elsewhere (ideally, in the constructor of the component it produces).

In C++ there are several options for the implementation of factories. Most are equivalent, so the choice is really one of personal preferences. One of the most versatile ways to implement a factory is (imho) std::function.

The dependency graph, including factories and factory abstractions, will look like this:


  +-------------------+      +-------------------------+
  | WindowFactoryBase |      | WindowHandleFactoryBase |
  +-------------------+      +-------------------------+
  ^         |         ^      ^        |               ^
  |         v         |      |        v               |
  |   +------------+  |      |  +------------------+  |
  |   | WindowBase |  |      |  | WindowHandleBase |  |
  |   +------------+  |      |  +------------------+  |
  |     ^          ^  |      |  ^     ^               |
  |     |          |  |      |  |     |               |
  |     |          |  |      |  |     |               |
  |     |        +---------------+    |               |
  |     |        | WindowManager |    |               |
  |     |        +---------------+    |               |
  |     |                             |               |
  |     |                             |               |
  |   +--------+                +--------------+      |
  |   | Window |                | WindowHandle |      |
  |   +--------+                +--------------+      |
  |        ^                              ^           |
  |        |                              |           |
 +---------------+                +---------------------+
 | WindowFactory |                | WindowHandleFactory |
 +---------------+                +---------------------+

When using std::function, the FactoryBases are little more than using declarations. Factories are brought into a component by passing them to the constructor.

It is advisable to use a single argument to pass all required factories. This can be performed using a small helper struct. Thus, the number of arguments stays constant even when the underlying implementation changes and depends on different factories. This is however not sufficient to obtain ABI stability, which is outside the scope of this post.

Composition root

You might be wondering how to pass the factories helper struct to sub-components. After all it is not possible to instantiate this struct in the component implementation without linking against the factory implementations - which would transitively link against the component implementations that these factories produce (I have seen this mistake many times).

You can probably guess the solution from the section title - the solution is a tree. The factory helper structs for a component's sub-components are passed to the component using the factory helper struct - but not directly. To avoid exponentially increasing size of helper structs, each helper struct defines a factory function for itself. This factory function returns a helper struct that is default-populated with factories for the production component implementations (as opposed to Mocks that would be produced during testing).

Such a struct - including the one on the top level(s) - therefore contains two types of factories:

  • One for each unit or component which is constructed by the implementation of the component that the factory helper struct is passed to and
  • one for each component which is constructed in the implementation and requires a factory helper struct of its own.

This tiered approach also results in limited, local changes if dependencies change and therefore, factories need to be added to or removed from the factory helper struct of a component.

Remember how I promised that iff all code (units and components) is unit-testable, all higher tier tests are always possible? Using a composition root is one of the key ingredients to successful integration testing.

The result of this process is a dependency graph where component implementations and their abstractions depend on abstractions only, while factories depend on lower-level factories. Circular dependencies among implementations are impossible, those among abstractions can much be resolved much easier. Occasionally, one might find a circular dependency among factories. In any case, circular dependencies are a sign of bad architecture (i.e. a missing subdivision into smaller units/components).

Instead of the type of dependency tree unfortunately found in most software, where high level software components depend on lower level components, this top-down relationship has been shifted to factories. This is advantageous because factories contain less complexity and DI is required for testing.

Higher-tier testing

Whatever you want to call it - integration testing, module testing, component-integration testing, etc. - it is all possible using factory helper structs and a composition root. This is because all higher tier tests are essentially just drawing the line somewhere other than at the unit boundary.

In a unit test of a component, all of the component's dependencies are "mocked away". All higher tier tests use at least one actual implementation of a dependency in the test. When the component is using a factory helper struct, it becomes easy to specify which components shall be mocked and which shall use the production implementation.

Moreover, it is possible to specify where in the hierarchy mocks shall be used. Let us assume the following simplified dependency graph:

               +---> D
               |
     +----> B -+
     |         |
  A -+         +---> E
     |         |
     +----> C -+

You want to write an integration test where A, B and C shall use production implementation code. Also, all instances of E created by B shall use production implementation code. Everything else shall be mocked. Using factory helper structs, this is easy (although it might require quite a few lines of code). First of all, B needs a factory helper struct which creates real instances of E, but mocks all instances of D. Secondly, C needs a factory helper struct which mocks all instances of E. Thirdly, A needs a factory helper struct which creates production implementation instances of B and C while passing the correct factories to all of their constructors (which can be done with the second type of factories in A's factory helper struct).

This case also highlights why link time substitution is not always an option: using link-time substitution it would be impossible to have E be both a mock and a production implementation instance in the same test run. Any attempt at making it possible would either require changes in the implementation or operating-system and/or linker vendor dependent linker invocations.

Substitution options

Various options exist to substitute implementations for mocks. They each have their advantages and disadvantages.

Abstract classes

These have been discussed in this post. There are a few complications when using abstract classes:

  • Virtual function calls
  • Dynamic memory
  • Value semantics

It is also a bit harder to maintain an overview - strict adherence to a directory structures is absolutely required. Note that abstract classes themselves do not require dynamic memory, however implementation becomes more complex, especially when dynamic behavior needs to be emulated (imagine e.g. our window manager without dynamic memory).

A note about value semantics: it is quite possible to still obtain value semantics while using abstract classes for DI. The "trick" is to use user-space virtual calls, also called "type erasure." When this pattern is employed, implementations still handle values of units or components they depend on. To implement it, a third piece of code is required for each unit and component: a "shim" class. This shim class has a single member, which is a pointer (usually a smart pointer). All methods are mirrored and delegated. Copy- and assignment constructors are implemented or deleted, depending on the capabilities of the component.

Note that since the pointer held by the shim class points to an abstract type, the copy constructor requires support from the implementation, which must also be present in the abstract base class (i.e. a copy() function or similar).

Since the shim class will not contain logic, it does not need to be tested explicitly - it will be implicitly tested since it sits between the implementation and the value representation of a component. Its function call delegations should be implemented inline (i.e. in the header) to allow the compiler to optimize alway the additional level of indirection. With a modern compiler, the performance penalty for this construction is eliminated and it is possible to work with value semantics, which is much more convenient than juggling pointers and references (most of that is due to not having to worry about lifetimes).

Templates

Templates are a great solution when virtual function calls and/or dynamic memory cannot be afforded. The downsides are:

  • Tests do not test the same binary. With abstract classes, it is easy to show that tests test the same binary (.so/.a/.dll/...) that is used in production. When compiling for a test and using a different template parameter, this will result in a different binary. Compiler optimizations might mean that more behavior is different than would be expected.
  • Templates are "viral". It is not possible to use composition roots because this would require each component using the template to be a template itself.
  • Everything that is a template needs to be defined in a header, which can lead to large binaries and slow compilation times. Older compilers will quickly get to their limits.

Before using templates, make sure that it is absolutely required. This might be the case when things have been proven to be performance critical. A suspicion is not sufficient.

Keep templates minimal and only use them at the lowest levels of the code. Higher level code is usually called less frequently and can afford runtime polymorphism.

Link time substitution

A unit test binary typically contains multiple tests. In each of these tests, the mock classes might exhibit different behavior, requiring a different implementation. It is therefore not possible to substitute non-abstract classes by performing substitution at link-time. Such tricks require a complicated build system and often lead to duplicated code which might diverge over time, either intentionally or unintentionally. This then results in tests being further away from production, dimnishing their value. It is therefore advisable to limit special tricks like link-time substitution to cases where all other options have been exhausted and keeping a close eye on the value of these tests.

Link time substitution requires ABI compatibility. Since there is no check for that (except something that amounts to a string comparison in the linker) it is extremely easy to introduce hard-to-find bugs into tests in this way.

© 2010-2021 Stefan Birgmeier
sbirgmeier@21er.org