Too Much Dependency Inversion!

Concrete Volatility is not your friend...

Posted by Jake Corn on January 13, 2020

Dependency inversion (or late binding as I think of it) is one of the best parts of Object-Oriented Programming. (yes, functional programming can do this too)

Dependency Inversion is the D in the SOLID acronym. If you need a briefer on SOLID get a copy of Clean Code

but for a small example. Let's imagine a class talking to a 3rd party authing service.

class User {
  getToken({ email, password }) {
    return GoogleAuthService.getToken({ email, password })
  }
}

Then we want to test it

class UserTest extends TestCase {
  async testGetToken() {
    const user = new User()
    const email = 'gooduser@gmail.com'
    const password = 'goodpassword'
    const token = await user.getToken({ email, password })

    this.assertEqual(typeof token, 'string')
  }
}

This depends on a running 3rd party auth service with a gooduser@gmail.com in its persistence mechanism.

That might not be a big problem. But we're not really testing any logic (our method calls their method doesn't give high confidence).

But then the requirement changes to validate some common rules about emails. So we add a new test to make sure we just get null if there is a bad email.

class UserTest extends TestCase {
  // ....
  async testGetTokenHandlesBadEmails() {
    const user = new User()
    const email = 'bad email@dot..com'
    const password = 'goodpassword'
    const result = await user.getToken({ email, password })

    this.assertNull(result)
  }
}

Then we write the code to make this pass.

class User {
  async getToken({ email, password }) {
    const emailValidator = new UserEmailValidator()
    const isValidEmail = await emailValidator.isValidEmail(email)
    if (!isValidEmail) {
      return null
    }

    return GoogleAuthService.getToken({ email, password })
  }
}

Let's assume that UserEmailValidator depends on being able to talk to the database to see if there is an existing user with that email.

Now transitively your test depends on the 3rtd party service and the database service up and running and having appropriate data inside.

This becomes extremely painful as you just try to verify simple things about your code, but have long-running dependencies.

Enter Dependency Inversion.

What if instead of depending directly on the GoogleAuthService and having difficulty testing that code, what if we could inject that dependency in the constructor.

class User {
  constructor({ authService, emailValidator }) {
    this.authService = authService
    this.emailValidator = emailValidator
  }

  async getToken({ email, password }) {
    const isValidEmail = await this.emailValidator.isValidEmail(email)
    if (!isValidEmail) {
      return null
    }

    return this.authService.getToken({ email, password })
  }
}

While that might seem more complex, it has actually pushed the direct dependency out of our User class, which makes it less brittle and more flexible by insulating it from concrete changes to the auth service and the email validator.

Our test now depends on abstractions.

class UserTest extends TestCase {
  // ....
  async testGetTokenHandlesBadEmails() {
    const authService = new TestAuthService()
    const emailValidator = new TestUserEmailValidator()

    const user = new User({ authService, emailValidator })
    const email = 'bad email@dot..com'
    const password = 'goodpassword'
    const result = await user.getToken({ email, password })

    this.assertNull(result)
  }
}

Another upshot to inverting the dependency is that we can now swap different dependencies at runtime.

The perceived downside is that the client needs to specify dependencies. This could be annoying but I would like to mention that it is now composable. It is actually simpler due to the reduction of transitive dependency. It is less volatile. It is more flexible. Are these things worth it for the annoyance?

Michael Feathers refers to this concept as finding "seams" in his book "Working Effectively with Legacy Code".

Working Effectively with Legacy Code

Let's compare extremes.

System A has 0 dependency inversion.

System B has all of its dependencies inverted to the point where values are only bound in the main method (application entry point).

Testing:

System A needs everything running all the time to test anything. Tests are slow due to integration. Tests are brittle due to the global state.

System B is easy to test and has a lot of tests. They run quickly and on commit phase mostly with the main method being the only one tested via integration test.

Deployment:

They both can be deployed after tests run. The likelihood of system A having enough test coverage to garner quality in a deployed environment is lower than that of system B therefore it is safe to assume that system B (if it has better automated testing) will be the easier one to deploy with confidence.

Development:

Working with a lot of dependency inversion means that the point where values are bound is always as late as possible, or as high up as possible. This means that there would often be much less indirection to trace in order to make the appropriate changes. This would have a huge effect on debugging as well.

In summary, inversion makes testing simpler and testing makes building software better.

-- jake