Aug 10, 20265 min read

Treading the Worn Water - Dependency Injection vs Dependency Inversion

Aug 10, 2026

There isn’t much to say about this topic that hasn’t already been said. They have been explored in depth, and there are many great resources out there to explain them. I don’t intend to repeat their explanations here for the sake of brevity, but I will explore this topic from my own perspective on how I struggled to truly differentiate between the two concepts for a longer time than I would care to admit.

I’ll include the short and plain definitions here for reference, but I encourage you to explore the topic further since these are genuinely important concepts.

Dependency Injection

Dependency Injection is a design pattern that allows a class to receive its dependencies from an external source rather than creating them itself. This promotes loose coupling and makes it easier to manage and test the code. This does not inherently mean they have to be interfaces, but they are usually used in that way to allow for more flexibility and easier testing.

This does mean that it is possible to use dependency injection while also coupling your code to concrete implementations. I like to say that you should not be taken with the idea of using interfaces where not necessary. Just using dependency injection does not mean you have to use interfaces, sometimes this can hurt more than it helps. If you are not going to be swapping out implementations, not writing tests against the interface, then using an interface is just unnecessary boilerplate. I have seen this done in many codebases, and it is a common mistake that I have made myself.

// This is a concrete implementation without an interface.
class ImageProcessor {
  processImage(image: Image, options?: ImageProcessingOptions): ProcessedImage {
    ...
  }
}

// We can inject this concrete implementation into another class, and it is still dependency injection, but it is not following the dependency inversion principle.
class BookingService {
  constructor(private imageProcessor: ImageProcessor) {}
  ...
}

Dependency injection by itself is also a useful pattern for the purpose of applying sole responsibility principle, and can be used to separate concerns in a codebase. It is a genuinely useful pattern that can be used in many different ways, and it is important to understand the context in which it is being used.

Dependency Inversion

Unlike dependency injection, dependency inversion is a principle that states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. This is easier to explain with an example.

Let’s say you have need to call an image processing api, cloudflare for example. You have a class that needs to call this api, and you have a class that implements the api client. If you were to use dependency injection, you would inject the api client into the class that needs to call it. This is fine, but it does not follow the dependency inversion principle. The high-level module (the class that needs to call the api) is still dependent on the low-level module (the api client). To follow the dependency inversion principle, you would create an interface that defines the methods that the high-level module needs to call, and then have the low-level module implement that interface. This way, both modules depend on an abstraction, and the high-level module is no longer dependent on the low-level module.

// This is the interface that defines the methods that the high-level module needs to call
interface ImageProcessingApi {
  processImage(image: Image): ProcessedImage;
}

If you are using dependency injection, you would inject an implementation of the ImageProcessingApi interface into the high-level module. This makes the high-level module dependent on the abstraction, rather than the low-level module.

class ImageProcessor {
  constructor(private api: ImageProcessingApi) {}
  process(image: Image): ProcessedImage {
    return this.api.processImage(image, options);
  }
}

We can use this pattern to also route different file types to different implementations of the api, for example, if we wanted to use a different api for processing png images, we could do that by creating a new implementation of the ImageProcessingApi interface that handles png images, and then inject that implementation into the ImageProcessor class when processing png images.

class PngImageProcessingApi implements ImageProcessingApi {
  processImage(image: Image): ProcessedImage {
    // Implementation for processing png images
  }
}

Then in the ImageProcessor class, we could inject the PngImageProcessingApi implementation when processing png images.

interface ProcessingMap {
  default: ImageProcessingApi;
  [extension: string]: ImageProcessingApi;
}

class ImageProcessor {
  constructor(private processingMap: ProcessingMap) {}
  process(image: Image): ProcessedImage {
    const extension = image.getExtension();
    let api = this.processingMap[extension];
    if (!api) {
      api = this.processingMap.default;
    }
    return api.processImage(image, options);
  }
}

This way, we can easily swap out different implementations of the ImageProcessingApi interface for different file types, and the high-level module (the ImageProcessor class) is no longer dependent on the low-level module (the specific api client implementation). The above is also an example of composition over inheritance. We are ‘composing’ the ImageProcessor class with different implementations of the ImageProcessingApi interface, rather than inheriting from a base class. This allows more flexibility and assign a single responsibility to each class, and makes it easier to test and maintain the codebase later.

Dependency Inversion without Dependency Injection

While it is not often seen, it is possible to apply the dependency inversion principle without using dependency injection. This can be done by using a service locator pattern or a factory pattern to create instances of the low-level modules. This way, the high-level module is still dependent on the abstraction, rather than the low-level module, but it is not dependent on the specific implementation of the low-level module.

Dependency Inversion with a Factory Pattern

We could create a factory class that creates instances of the ImageProcessingApi interface based on the file type.

class ImageProcessingApiFactory {
  static create(extension: string): ImageProcessingApi {
    switch (extension) {
      case "png":
        return new PngImageProcessingApi();
      case "jpeg":
        return new JpegImageProcessingApi();
      default:
        return new DefaultImageProcessingApi();
    }
  }
}

and then in the ImageProcessor class, we could use the factory to create instances of the ImageProcessingApi interface based on the file type.

const imageProcessingApiFactory = new ImageProcessingApiFactory();

class ImageProcessor {
  process(image: Image, options?: ImageProcessingOptions): ProcessedImage {
    const extension = image.getExtension();
    const api = ImageProcessingApiFactory.create(extension);
    return api.processImage(image, options);
  }
}

Dependency Inversion with a Service Locator Pattern

In service locator pattern, we register the different implementations of the ImageProcessingApi interface, and then retrieve the appropriate implementation based on the file type.

class ImageProcessingApiServiceLocator {
  private static services: { [extension: string]: ImageProcessingApi } = {};

  static register(extension: string, service: ImageProcessingApi) {
    this.services[extension] = service;
  }

  static get(extension: string): ImageProcessingApi {
    return this.services[extension];
  }
}

When the application starts, we would register the different implementations of the ImageProcessingApi interface with the service locator.

ImageProcessingApiServiceLocator.register("png", new PngImageProcessingApi());
ImageProcessingApiServiceLocator.register("jpeg", new JpegImageProcessingApi());

And then in the ImageProcessor class, we could use the service locator to retrieve the appropriate implementation of the ImageProcessingApi interface based on the file type.

class ImageProcessor {
  process(image: Image, options?: ImageProcessingOptions): ProcessedImage {
    const extension = image.getExtension();
    const api = ImageProcessingApiServiceLocator.get(extension);
    return api.processImage(image, options);
  }
}

Both the above examples are valid ways to apply the dependency inversion principle without using dependency injection. However, it is important to note that using dependency injection is generally considered a better practice, as it promotes loose coupling and makes it easier to manage and test the code.

Why was I confused?

I was less confused and more impatient. I wanted to do loose coupling, and I wanted to do it quickly. It was ‘cool’, for the lack of a better term. I wanted to use dependency injection, and I wanted to use interfaces, and I wanted to do it all at once. I was not thinking about the context in which I was working, and I was not thinking about the trade-offs of using interfaces where they were not necessary. I was not thinking about the fact that using interfaces where they are not necessary can actually hurt more than it helps.

If you are like me, you may have been confused about the difference between dependency injection and dependency inversion because they are often used together, and they both promote loose coupling. As ashamed it is to admit, I skimmed the surface of the topic, and did not fully understand the difference between the two concepts.

It is important to understand the difference between the two concepts, and to understand that they are not mutually exclusive. You can use dependency injection without following the dependency inversion principle, and you can follow the dependency inversion principle without using dependency injection.

Passing something in isn’t the same as turning it upside down

Conclusion

Sometimes the lesson to learn is that there is no lesson to learn. I have spent a lot of time thinking about this topic, and I have come to the conclusion that there is no one right way to do things. There are many different ways to achieve loose coupling, and it is important to understand the context in which you are working, and to choose the right tool for the job. I have only reached this conclusion after a lot of trial and error, and a lot of reflection on my own experiences. One must do the wrong thing to learn the right thing. If you are always right the first time, you are not learning anything. You are just doing what you already know how to do. You are not growing as a developer, and you are not learning anything new.

Read, think, learn, and then do what makes sense for your situation. Don’t get caught up in the semantics of the terminology, and don’t get caught up in the dogma of the community. Do what makes sense for your situation, and don’t be afraid to experiment and try new things. You might not even need to use dependency injection or dependency inversion at all. Sometimes the simplest solution is the best solution.

Just have fun, and build something beautiful along the way. But dont be afraid to take a step back and think about what you are doing, and tread the worn water. There is a lot of wisdom in the old ways, and sometimes the best way to learn is to take a breather and look at the bigger picture.

Share