Dependency Inversion and Injection in Python and Go

I want to write about dependency injection and what I have learned (or think I have learned) after starting to study and work with some golang, coming from a background of using mainly python, and also I want to share how I think some of the same awesome principles and patterns used in Go through interfaces can also be applied in python to make you code more decoupled, easier to maintain and nicer to look at.

The Fake Dependency Injection Case and How to Solve it

I consider a dependency injection to be a fake one when in the init of a class, too many parameters are required for the other classes that need to be injected. This was even before getting into Go, now I also consider when concrete classes are required, while only their behavior should. What this approach achieves, in my opinion, is only to complicate things in the long run, create nightmare fueling refactor tasks that will always make you question whether things will work after the refactor, and create constructors/init methods that take 200 parameters and/or are 200 lines long.

An example:

  class MyNightmareClass:
      def __init__(self, reader: FileReader, writer: FileWriter, start_file: str, end_file: str, max_size: int, *args, **kwargs):
          self.reader = reader
          self.writer = writer
          self.start_file = start_file
          self.end_file = end_file
          self.max_size = max_size

Data and Behavior

The presented nightmare class has tightly coupled dependencies in the "constructor". These dependencies, when introduced like so, carry with them complexity, making it difficult to refactor our code when the codebase starts growing and demanding changes. We can opt to increase the number of parameters, but eventually this ever increasing balloon will explode right in our face. Otherwise, as a starting point, we can try to differentiate between the data that we are passing, and the behavior that we are passing (injecting). The data, in this case, is the start_file, end_file and the max_size. Python offers dataclasses, a nice way to group this type of information within a class and even make it efficient by freezing the dataclass in case we know the values should not be changed once created the object.

  from dataclasses import dataclass

  @dataclass(frozen=True)
  class FileConfig:
      start_file: str
      end_file: str
      max_size: int

  class MyNightmareClass:
      def __init__(self, reader: FileReader, writer: FileWriter, file_config: FileConfig, *args, **kwargs):
          self.reader = reader
          self.writer = writer
          self.file_config = file_config

By introducing a FileConfig dataclass to encapsulate the start_file, end_file, and max_size parameters, we create a clear separation between data and behavior. This improves code organization and readability while enabling us to freeze the dataclass to prevent changes after object creation. Moreover, dataclasses usually make the code more readable and can allow you to make the data they contain immutable by using the frozen=True parameter in the decorator, this not only improves performance, but allows you to make sure that users of your dataclass cannot change its data once the object is created.

Let's see how we can tackle the behaviors now. We have two dependencies that we can link to behaviors: reading and writing. Someday we may need to write the output of our class to a database, while reading from a s3 bucket, however, the basic behavior behind these is always the same: read and write. For this, we can take advantage of yet another feature that was introduced in Python 3.8: Protocols. Classes that have attribute or method signatures that match the ones of a protocol are implementing it, and therefore when using certain tools, like mypy, we can check whether the classes we are designing implement correctly the protocol we want. In the following example we define a protocol for Writer and Reader and we use those in the type hinting of the MyNightmareClass init:

  from dataclasses import dataclass
  from typing import Protocol

  @dataclass(frozen=True)
  class FileConfig:
      start_file: str
      end_file: str
      max_size: int

  class Reader(Protocol):
      def read(self, path: str) -> bytes:
          ...

  class Writer(Protocol):
      def write(self, path: str, content: str):
          ...

  class MyNightmareClass:
      def __init__(self, reader: Reader, writer: Writer, file_config: FileConfig, *args, **kwargs):
          self.reader = reader
          self.writer = writer
          self.file_config = file_config

By doing this, we are inverting the dependencies between our higher level class (MyNightmareClass) and our lower level classes (FileReader, FileWriter), since now the higher level class depends on abstractions of the FileReader and FileWriter classes.

An example of a class that would implement the writer protocol can be the following:

  class MyFileWriter:
      def write(self, path: str, content: str):
          with open(path, "w") as f:
              f.write(content)

What becomes apparent, is that if we wanted to swap the file writer with a database writer or a standard output writer, it is easier now to create a DBWriter or StdOutWriter and pass them to the MyNightmareClass init. With this we have achieved the ability of creating easily swappable components that can also be reused in other higher level classes. And we have done this without having to use inheritance.

Happy Class

Finally, we can do the same with our nightmare class as well, and make it a Protocol or an abstract class. I will use a protocol for this case so to highlight some peculiarities that you have to take into account when defining attributes of a protocol. So, our nightmare class as a nice happy class protocol will look like this:

  class HappyClass(Protocol):

      @property
      def reader(self) -> Reader:
          ...
      @property
      def writer(self) -> Writer:
          ...
      @property
      def file_config(self) -> FileConfig:
          ...

How do we test this?

There are different options to check whether the classes you create are implementing the protocol correctly. One is to use mypy, it will report an error if the class you are passing to a type hinted function does not implement the protocol wanted by the function correctly, or you can rely on your editor/IDE and the language server it uses to report the issue as you are writing your code.

  class MyRainbowClass:
          
      def __init__(self, reader: Reader, writer: Writer, file_config: FileConfig):
          self.reader = reader
          self.writer = writer
          self.file_config = file_config

  def test(x: HappyClass):
      return x

  file_config = FileConfig("first_file.txt", "last_file.txt", 150000)
  writer = MyFileWriter()
  reader = MyReader()
  my_rainbow_class = MyRainbowClass(reader, writer, file_config)
  test(x)

In this example, the test function will be a function that takes as a parameter any class that implements any of the protocols we earlier defined.

If you pass an object that does not successfully implement the protocol expected, the call to the test function will be highlighted as an error by the LSP of your IDE or if you use mypy it will be reported as an error.

Dependency injection and inversion in Golang

A comparison between Golang and Python

In Go you can achieve dependency inversion and decoupling by using interfaces. By dependency inversion we mean that we do not want high level modules to depend on low-level ones, and in order to achieve that we make both the low-level module and the high-level one depend on an abstraction that we will be the behavioral contract which are needed by the higher level module. Let's look at an example of this, in the example we are defining the structs for our text based game. In this game we have characters, and characters can wield different types of weapons:

  package main

  import "fmt"

  type Weapon interface {
          damage() int64
  }

  type Knife struct {
  }

  // damage implements Weapon
  func (Knife) damage() int64 {
          return 5
  }

  type Character struct {
        w           Weapon
        base_damage int64
  }

  func (c *Character) attack() int64 {
          return c.w.damage() + c.base_damage
  }

  func main() {
          k := Knife{}
          c := Character{w: k, base_damage: 5}
          fmt.Println(c.attack())
  }

In this example, you can see that we do not define a Character struct which requires a Knife struct, instead, we abstract the behavior of a weapon, which is that of dealing damage, and define an interface, a contract, Weapon, so that we can easily assign any kind of item to the character, as long as it is an item which can deal damage. We can also take different approaches, obviously, for example, if we know that only the attack function of a character requires weapons, we can inject the Weapon dependency in the attack function's parameters instead of making it part of the attributes held in Character:

  type Character struct {
	base_damage int
  }

  func (c *Character) attack(w Weapon) int64 {
          return w.damage() + c.base_damage
  }

In this way, we can swap weapons and use knives, swords, shovels or frying pans all the same, as long as they adhere to the interface and implement a damage function. This is way better than the alternative of inheritance, because with inheritance we may force all child classes of an abstract class Weapon to implement other maybe unwanted behavior or attributes.

Implementing the same definitions in Python would result in something similar to the following if we want to rely on interfaces and not on inheritance:

  from typing import Protocol, runtime_checkable


  @runtime_checkable
  class Weapon(Protocol):

      def dmg(self) -> int:
          ...


  class Knife:

      def dmg(self) -> int:
          return 5


  class KnifeStr:

      def dmg(self) -> str:
          return "5"


  class Character:

      def __init__(self, weapon: Weapon):
          self.weapon = weapon

      def attack(self) -> int:
          return self.weapon.dmg()


  c = Character(weapon=Knife())
  c = Character(weapon=KnifeStr()) # This should be an error for mypy

Conclusion

Learning Go has been pretty fun so far, some of the patterns like the one showed in this post I think helped me realize how much more convenient, maintainable and extendable using dependency inversion is compared to inheritance. Not saying inheritance should never be used, but if Golang and Rust manage to be very well alive and breathing without it, I do not see why avoiding using it to give preference to protocols may be an issue. It is true that at the moment it may be a bit tedious to check for correct implementation of a protocol at runtime in Python, but as a design choice in general, the upsides of abstracting the contract between components seem to outweigh the downsides of maintaining and extending classes with layers of inheritance.