💉 Understanding Dependency Injection (DI) by Analogy

💉 Understanding Dependency Injection (DI) by Analogy

·

12 min read

Preface

Have you encountered Dependency Injection (DI)? Or heard it somewhere? You're wondering what is this for? Is this a framework feature? Is this a vaccine injection? Definitely not!

Dependency Injection is a concept, a pattern that can help you manage your dependency on different objects. These objects are then used/consumed by a client.

tl;dr

Dependency Injection is by having a keyboard peripheral device (service/detail) connected to a USB port interface (abstraction), that you can easily inject into the Computer (client) for it to perform an action—type characters—and the device can be easily replaced with a different one, with a mouse (service/detail). This way you can replace things easily by "injecting" the peripheral devices with a USB cable to a USB port of your Computer.

Feel free to ignore the words inside the parenthesis. We will see this in action later on and explain each different component in much more detail.

Dependency Injection (DI) Definition

In software engineering, dependency injection is a technique in which an object receives other objects that it depends on, called dependencies. Typically, the receiving object is called a client and the passed-in (injected) object is called a service.

Definition of DI from Wikipedia. (Relax...this is just another way for you to form a memory chunk!)

Components

Now, let's start diving into the Dependency Injection Analogy! You would notice that it has multiple components: Service/Details, the one who does the heavy lifting, contains the details, or the "object" itself, the one you are passing to your Client so it can use it. Abstraction/Interface is the one who defines the contract (set of rules) for connecting the Service/Details and your Client. The Client is the one who consumes or uses the Service/Details.

🖱Device is the Details

Let's start with the Details component. You would notice that each of our Devices (details) has its USBs!

details dependency injection.png

You can use different devices and connect to a computer as long as they have the USB interface and following the standard USB type. For this case, we're using USB-A type. Every device must have this for us to inject into a computer that has the USB-A type port.

USB Port/Interface is the Abstraction

The USB interface is the component that creates the contract, the rules. It must define what would be the shape (rectangle), width (12 mm), height (4.5 mm), pins (9), identifies the peripherals/devices on how much energy is required to power it on, and many more! (used the USB-A 3.1 plug for the specification rule - wiki)

The contract created for both the Computer and Devices must follow the rules associated with it. To put it simply, they must fit when connected.

usb blueprint.png

This will allow our Computer (client) to read the Devices (detail) by just "injecting" their USBs (abstraction) to our port. You can easily switch different devices. Inject a keyboard? No problemo. Inject a mouse, printer, or camera? No problemo.

flow_peripheral_usb.png

Our USB port can accept any device as long as they have a USB cable that follows these rules. Note that the USB port is soldered directly to our Computer (client), and our Devices has a cable or USB soldered to it.

💻 Computer is the Client

The Computer is the one that will use the information passed by the peripheral devices, it also "uses" the devices to perform an action.

computers.jpg

The Computer has USB ports directly attached (soldered) to it.

flow_computer_usb.png

Our Computer doesn't need to know anything about how typing works on a keyboard. It doesn't need to learn how these things were built: their shape and layout. Is it a 60% layout? A mechanical type keyboard? It only needs to know how to interpret what message these devices send through our USB interface.

The Big Picture

We can now see that by having the USB ports soldered directly to your Computer, you can easily switch different devices that our Computer can use as long as they have the USB interface. This will result in both the Computer and Devices have a dependency on our USB interface. The computer has a USB port interface, the peripheral devices that follow the rules (shape, size, pins, etc.) can be inserted into the USB port interface.

computer with usb port.jpg

Your Computer can now:

  • Move the cursor using the Mouse via USB interface
  • Type letters or characters using the Keyboard via USB interface
  • Transfer files using the Flash Drive via USB interface
  • Wanna Print a meme? well... use a Printer via USB interface!

Adding to this, they can easily be swapped. Finished using the printer? Pull it out and inject that external Hard Drive that contains the videos of your cat 😸. Your Computer doesn't need to depend on any device, it only needs to know how to communicate with your USB interface.

The design diagram will now look like this:

flow_complete.png

Both Computer (client) and Devices (detail) knows about the USB interface. The Computer has a USB port, while each Device has their cable that can connect to the USB port. But our Computer is not directly connected with any device.

Imagination is for experimentation

Let's use our imagination! Now, imagine this:

usb_interface_doesnt_exist.jpeg

We would directly solder our peripheral devices to the computer's motherboard! Just imagine that! Replacing your keyboard with a mouse, you have to desolder the keyboard and solder the mouse. It’s a disastah, and painfully slow as they are tightly coupled and hard to manage. By using the DI pattern, we can easily change devices just by injecting it into the USB interface!

This, of course, doesn't apply to only the USB interface. Looking at you HDMI and audio Jack!

jack.jpg

No! Not that Jack, I should've said jack (grammar is hard, send halp!). I'm talking about the audio jack, HDMI, and there's much more!

jack_headset.png

No matter what your device is, your TV, what type of your headset is. As long as both client and your details have the same contract and rules applied, they can communicate properly. With that, you can now see the Dependency Injection works in the real world! Let’s go back to our world…the software world 🤓…

💻 Show Me The Code!

enough-blah-blah.jpg

I hear you say:

Enough with your bad analogies and drawings! Just show me the code! 😡😡😡

Alright, alright...please stay calm...

stay_calm.gif

Creating the Interface

Let's start coding the USB interface. It will help us understand what our clients can do with our devices. The software can model a part of our world. The model will depend on the context of what you are trying to solve.

If you are trying to create a theatre reservation system, you will not build software that knows to play a movie. What you will construct is a representation of how users can reserve and occupy seats. You will model the software to the heart of the problem.

For our case, we will build a way to display the device's information. It will help us identify the device plugged into the computer. This way, we can view the device name and manufacturer.

public interface USB {

    String getDeviceName();

    String getDeviceManufacturerName();

}

Note: For the following code snippets. I will be using Java 11 here, but this doesn't mean that it can only work in Java. It can also work in different languages.

In our code, we have created a contract: there are two rules. We're saying that the USB interface has two methods. Both of them return a String data type.

  • getDeviceName() rule says you need to provide the device name as a String representation.
  • getDeviceManufacturerName rule says you need to provide the manufacturer name as a String representation.

With these rules, a contract is prepared for both our Client and Devices to be agreed upon.

You can, of course, use a different data-type, or even accept an argument to your rules. If you're interested, check out the Refactoring section later. For now, we will use a simple one.

Implementing the Interface

Let's say you have a brand-new mouse, and it's called "Magic MZ 3" created by the "ZT3ch Manufacturer". We can represent this as:

public final class MagicMz3Mouse implements USB {

    @Override
    public String getDeviceName() {
        return "Magic MZ 3 Mouse - Ergonomic";
    }

    @Override
    public String getDeviceManufacturerName() {
        return "ZT3ch Manufacturer";
    }

}

This is where our Device agrees to the contract we have created. It must follow all the rules, no exceptions!

Another brand-new device is your keyboard. It is named "Ultimate MechZ Keyboard" created by the "Z3R0 Manufacturer".

public final class UltimateMechZKeyboard implements USB {

    @Override
    public String getDeviceName() {
        return "Ultimate MechZ Keyboard";
    }

    @Override
    public String getDeviceManufacturerName() {
        return "Z3R0 Manufacturer";
    }

}

Building your Computer

As we have stated before, the computer will own the USB interface, and we will inject our devices to that interface which they follow or implement the rules. This is a composition, the Computer has a USB. The Computer knows that it is a contract, and it guarantees that the rules (methods) exists, that is why it can use it without hesitation (there are times that there is no action/statements inside the method, but they still have followed and created the method).

public final class Computer {

    private USB usb;

    // constructor injection
    public Computer( USB usb ) {
        this.usb = usb;
    }

    // method injection
    public void injectNewDevice( USB usb ) {
        this.usb = usb;
    }

    public void readDevice() {
        System.out.println( "Starting to read the device information..." );
        System.out.println( "-----" );
        System.out.println( "Device name: " + usb.getDeviceName() );
        System.out.println( "Device Manufacturer name: " + usb.getDeviceManufacturerName() );
        System.out.println( "-----" );
        System.out.println( "Finished reading the device information.\n" );
    }

}

Currently, our Computer can only do one thing for now. It can read and print the device information of whatever device we have injected into the usb. You'll notice that we can inject usb into two different parts, the constructor and the method.

Let's run it! First, we need to build our Computer.

final var myComputer = new Computer(...);

The Computer needs a USB to run (you can argue that this design is awful, computers don't need to inject a USB device for it to run. We will work on this in the Refactoring section. For now, let's stick with it and display the information of the injected device).

Let's inject our "Magic MZ 3" first!

final var myComputer = new Computer( new MagicMz3Mouse() );
myComputer.readDevice();

This is where the magic happens, we have just passed in an instance of the mouse (MagicMz3Mouse) object, and the Computer can use it without knowing what type of device the object was passed in. It can use it as long as it abides with the USB contract.

The output is...

Starting to read the device information...
-----
Device name: Magic MZ 3 Mouse - Ergonomic
Device Manufacturer name: ZT3ch Manufacturer
-----
Finished reading the device information.

Now, we can easily replace that device with our "Ultimate MechZ Keyboard"!

final var myComputer = new Computer( new UltimateMechZKeyboard() );
myComputer.readDevice();

And the output is...

Starting to read the device information...
-----
Device name: Ultimate MechZ Keyboard
Device Manufacturer name: Z3R0 Manufacturer
-----
Finished reading the device information.

We can also inject the dependencies on the fly by using the method injection.

final var myComputer = new Computer( new MagicMz3Mouse() );
myComputer.readDevice();
myComputer.injectNewDevice( new UltimateMechZKeyboard() );
myComputer.readDevice();

It'll output:

Starting to read the device information...
-----
Device name: Magic MZ 3 Mouse - Ergonomic
Device Manufacturer name: ZT3ch Manufacturer
-----
Finished reading the device information.

Starting to read the device information...
-----
Device name: Ultimate MechZ Keyboard
Device Manufacturer name: Z3R0 Manufacturer
-----
Finished reading the device information.

As you can see, our Computer can easily read ANY Devices as long as these devices follow or implement the rules defined by our USB interface. So if your device only implements HDMI, then you can't inject that into our USB! Just like in the real world 😉…

Refactoring

This is a bonus section if you want to see how we can extend our application.

The first thing I'd like to introduce is to improve our design in Computer, we do not need a USB to start it, but we also want to start it with a USB already injected into it.

private Computer() {
}

// constructor injection
private Computer( USB usb ) {
  this.usb = usb;
}

public static Computer newInstance() {
  return new Computer();
}

// indirect constructor injection
public static Computer with( USB usb ) {
  return new Computer( usb );
}

We have introduced a static method to improve our API and multiple ways to instantiate our Computer. I have also removed the method .injectNewDevice(...).

We can now start our Computer without a USB.

final var myComputer = Computer.newInstance();

But better is, we can start our Computer with a USB, then read the injected device.

Computer
  .with( new MagicMz3Mouse() )
  .readDevice();

As promised, we're going to refactor our interface. Let's create a new class DeviceManufacturer. This class is a POJO and a data class (Record class for Java 16) that contains little information about the Device Manufacturer. You can add as many properties as you want. For demonstration purposes, I only used two properties: name and description.

public final class DeviceManufacturer {

    private final String name;

    private final String description;

    private DeviceManufacturer( String name, String description ) {
        this.name = name;
        this.description = description;
    }

    public static DeviceManufacturer of( String name, String description ) {
        return new DeviceManufacturer( name, description );
    }

    public String getName() {
        return name;
    }

    public String getDescription() {
        return description;
    }

    @Override
    public String toString() {
        return "DeviceManufacturer{" +
            "name='" + name + '\'' +
            ", description='" + description + '\'' +
            '}';
    }
}

Then let's update our USB interface to use DeviceManufacturer data-type instead.

public interface USB {

    String getDeviceName();

    DeviceManufacturer getDeviceManufacturerName();

}

Then let's update our Device details!

public final class MagicMz3Mouse implements USB {

    private MagicMz3Mouse() {}

    public static MagicMz3Mouse newInstance() {
        return new MagicMz3Mouse();
    }

    @Override
    public String getDeviceName() {
        return "Magic MZ 3 Mouse - Ergonomic";
    }

    @Override
    public DeviceManufacturer getDeviceManufacturerName() {
        return DeviceManufacturer.of(
            "ZT3ch Manufacturer",
            "Your number one tech manufacturer!"
        );
    }

}

Finally, we can now use it!

Computer
  .with( MagicMz3Mouse.newInstance() )
  .readDevice();

Output:

Starting to read the device information...
-----
Device name: Magic MZ 3 Mouse - Ergonomic
Device Manufacturer: DeviceManufacturer{name='ZT3ch Manufacturer', description='Your number one tech manufacturer!'}
-----
Finished reading the device information.

Give it a try! Refactor the keyboard device and use it.

Conclusion

We have discussed what Dependency Injection is all about, how it is present in our world and used day to day. We can leverage it to manage our dependencies with our clients and objects. It is quite simple, yet powerful. Have fun injecting!

You can view the source code here.

This blog is part of my learning process for writing and communication, to share my knowledge and explain things simply. Please reach out to me if there are things you know that I can improve.