Introducing Interfaces

Build your program before turning it on.

With consistent application of dependency injection, object composition is pushed to the entry-point of our program with the appearance of the composition root.

Last chapter, we observed that the composition root represents a separation of the concerns of object composition from the rest of our business logic.

One key benefit of having object composition separated like this, is there is a centralized location where we can select which concrete implementations of our program’s interfaces we’ll use to compose our program.

What even are Interfaces?

Here’s a common definition for Interfaces:

💡 Interfaces describe the set of interactions supported by an object.

In languages like TypeScript, this is going to be the public methods, properties and fields of an object.

Objects which support the same set of methods, properties and fields as an interface are said to “implement the interface”.

Interfaces are ambivalent

In our previous examples, we used the simple example of a class Foo with a dependency on class Bar.

However, this is pretty inflexible! If we ever want to change what type Foo is dependent on, we’d have to modify Foo accordingly. Let’s say we wanted Foo to depend on Bur instead:

Before & After

class Foo {
 constructor(private bar: Bar)
 run() { this.bar.waggle() }
}
class Foo {
 constructor(private bur: Bur)
 run() { this.bur.waggle() }
}

Imagine that Foo is some venerable class that has a good test suite, has been working in production for a long time and has served us well.

Having to modify Foo is a minor tragedy. Not only is modification the doorway to regressions, even if a test suite catches it, it’s still additional work to fix the test.

By implementing Foo, not in terms of some concrete type, but instead an interface, we make Foo much more flexible:

interface Waggler {
 waggle(): void
}

class Foo {
 constructor(private waggler: Waggler) { }
 run() { this.waggler.waggle() }
}

Now Foo doesn’t care whether it receives a Bar or a Bur or any other type that implements our Waggler interface.

💡 This is the Open/Close Principle in action!

We can change how Foo behaves without modifying it directly. We do this by changing what object Foo delegates to.

This is made possible because:

  • Foo delegates the details of logging messages (Distillation)
  • Foo receives this delegate from elsewhere (Injection)
  • Foo specifies the delegate as an interface (Substitution)

A Concrete Example of Interfaces

Let’s explore a concrete (pun very much intended) example of how using interfaces can draw out the value of dependency injection and our composition root.

Consider the following interface:

interface Logger {
 log(message: string): Promise<void>
}

Let’s use it in a trivial example:

class App {
 constructor(private logger: Logger) { }
 async start() {
     await this.logger.log("App started.")
 }
}

A simple App class depends on using a Logger to emit a message that it has started up.

However, notice that App doesn’t actually care what kind of logging object it is provided. As long as that object has the right log method, App is happy to use it.

Let’s introduce two concrete implementations:

class ConsoleLogger {
 async log(message: string) {
    console.log(message)
 }
}

class NullLogger {
 async log(message: string) 
}

Both loggers implement the necessary log function, but behave in different ways.

Selecting a logger in the Composition Root

In our composition root, we can dynamically choose which implementation to provide to our App class:

function main() {
 const logger = process.env.DEBUG
 ? new ConsoleLogger()
 : new NullLogger()
 const app = new App(logger)
 app.start()
}

In theory, we could introduce a third logger for production that sent logs off to some third-party service. And we could do that without having to modify Foo, potentially introduce regressions in Foo, or break its unit tests.

Additionally, we don’t need a single logger that’s concerned with all these different environments and use-cases. All of this “what mode should we operate in”-logic is separated from both our central business logic (Foo in this case) and support services (the Logger implementations).

💡 Dynamically choosing which concrete implementations to use for your program’s interfaces is an example of Late Binding.

There are many things that Late Binding can facilitate.

We’ll mention one for now: 
    Automated testing without magic

Since we have full control over which concrete types we use for the interfaces in our programs, we can supply mocks or fakes to our components under test. No need for patching libraries, complex state, and so on. We’ll explore testing more in a later chapter.

P.S. It is “late” in that types are selected at runtime rather than compile time; even though we do it early during startup.

Cyclomatic Complexity

“Cyclomatic Complexity” (or CYC) is a fancy term referring to how many forking paths exist in the execution behavior of a program.

As the CYC of a given program or module increases, it becomes more complex and harder to reason about. The higher CYC of a given unit, the more tests are required to cover all of the supported cases of that unit

Worse, these conditions can appear nested within each other. This creates a compounding complexity where it takes a multi-dimensional matrix to fully express all the various modalities of the code.

To Log or Not To Log

In the above logging example, instead of having a single logging class that concerned itself not only with logging — but whether it should log, where it should log and so on — we opted to implement each case as a separate class, all implementing the same interface.

This allows us to attack some of the cyclomatic complexity within our programs.

Instead of having these choices spread throughout our program, intermingling with our business and domain logic — we make them up front.

It’s true that this technically does not reduce the cyclomatic complexity of our programs as a whole. We are still making these choices, after all, just in a different place. However it does indeed reduce the CYC of the modules containing our business logic. And the separation of concerns between business concerns and program composition concerns results in better expressivity for both.

Extrapolating from logging to the wide variety of concerns in production programs, you may start to imagine how wide-spread expressivity may be possible.