bigonotes
CS ConceptsSOLID Principles

Dependency Inversion Principle

How depending on abstractions and injecting dependencies decouples high-level code, and why constructor injection is the default.

5 min readLast updated SOLID Principles

The Dependency Inversion Principle (DIP) has two parts:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

The short version: depend on interfaces, not concrete classes. It's what lets you swap a database, mock a service in tests, or change an email provider without rewriting business logic.

The idea#

You don't hardwire your TV into the building's wiring. You plug it into a standard socket. Any device with the right plug works with any socket, and you can swap devices without rewiring the house. The socket is the abstraction.

Before: UserService points directly at MySQLDatabase. After: UserService and both database classes point at a Database interface

Violation#

UserService builds its own MySQL connection, so it is welded to that database:

class MySQLDatabase:  # low-level detail
    def save_data(self, data: str) -> None:
        print(f"Saving to MySQL: {data}")


class UserService:  # high-level policy
    def __init__(self):
        # Hardwired: switching databases means editing this class
        self._database = MySQLDatabase()

    def save_user(self, user: str) -> None:
        self._database.save_data(user)


UserService().save_user("ana")

Fix#

Introduce an abstraction that the service owns, and pass an implementation in:

from abc import ABC, abstractmethod


class Database(ABC):  # the abstraction
    @abstractmethod
    def save_data(self, data: str) -> None: ...


class MySQLDatabase(Database):
    def save_data(self, data: str) -> None:
        print(f"Saving to MySQL: {data}")


class MongoDatabase(Database):
    def save_data(self, data: str) -> None:
        print(f"Saving to MongoDB: {data}")


class UserService:
    def __init__(self, database: Database):  # dependency is injected
        self._database = database  # depends only on the abstraction

    def save_user(self, user: str) -> None:
        self._database.save_data(user)


mysql_service = UserService(MySQLDatabase())
mongo_service = UserService(MongoDatabase())  # no change to UserService
mongo_service.save_user("ana")

Benefits:

  1. Swap implementations without touching high-level code.
  2. Test easily by injecting a mock or in-memory database.
  3. Code is more flexible and easier to maintain.

The "inversion" is that the dependency arrow now points from the concrete database up to the interface that the high-level module owns, instead of from the service down to the database.

DIP vs dependency injection#

They're related but not the same thing:

Dependency Inversion (DIP) Dependency Injection (DI)
What it is A principle: what to do A technique: how to do it
Says Depend on abstractions Pass dependencies in from outside instead of creating them inside
class Engine:
    def start(self) -> str:
        return "vroom"


class V8Engine(Engine):
    pass


# Violates DIP: creates its own dependency
class HardwiredCar:
    def __init__(self):
        self._engine = V8Engine()


# Follows DIP via DI: depends on Engine, receives an implementation
class Car:
    def __init__(self, engine: Engine):
        self._engine = engine


Car(V8Engine())

DI is the main way you implement DIP.

Three kinds of injection#

A dependency can arrive through the constructor, a setter, or the method that needs it:

from typing import Protocol


class Database(Protocol):
    def save(self, user: str) -> None: ...


# 1. Constructor injection (most common)
class ConstructorUserService:
    def __init__(self, db: Database):
        self._db = db


# 2. Setter injection
class SetterUserService:
    def __init__(self):
        self._db: Database | None = None

    def set_database(self, db: Database) -> None:
        self._db = db

    def save_user(self, user: str) -> None:
        self._db.save(user)  # fails if set_database was never called


# 3. Method injection
class MethodUserService:
    def save_user(self, user: str, db: Database) -> None:
        db.save(user)

At the composition root (e.g. main()), you build the concrete objects and wire them together:

database = MySQLDatabase()
email = SmtpEmailSender()
service = UserService(database, email)

# In tests, inject fakes instead
test_service = UserService(InMemoryDatabase(), email)

Why constructor injection is usually best#

Constructor injection wins for three reasons:

  1. Dependencies are guaranteed. You can't create the object without them, so they're never None.
  2. The object is ready immediately. With setter injection, forgetting set_database() leads to an AttributeError (or a None dereference) later.
  3. Stability. With no setter, the dependency can't be swapped out unexpectedly. Annotate it as Final if you want a type checker to enforce that.
service = SetterUserService()  # setter-injection style
service.save_user("ana")       # AttributeError: _db is None
Injection Use for
Constructor Required dependencies; the default choice
Setter Optional dependencies, or ones that legitimately change later
Method Dependencies that vary per call

All three in one class:

class EmailService:
    def __init__(self, server: SmtpServer):  # required: constructor
        self._server = server
        self._logger: Logger | None = None

    def set_logger(self, logger: Logger) -> None:  # optional: setter
        self._logger = logger

    # Varies per call: method injection
    def send_email(self, email: Email, formatter: Formatter) -> None:
        self._server.send(formatter.format(email))

A Car that can't work without an Engine should take it through the constructor.

All five principles together#

DIP usually ties the other four together. This small storage example uses every SOLID principle:

from abc import ABC, abstractmethod


# I: small, focused interfaces
class Readable(ABC):
    @abstractmethod
    def read(self) -> str: ...


class Writable(ABC):
    @abstractmethod
    def write(self, data: str) -> None: ...


# D: the abstraction high-level code depends on
class Storage(Readable, Writable):
    pass


# O: new providers extend this; nothing existing changes
class CloudStorage(Storage):
    def __init__(self, endpoint: str):
        self._endpoint = endpoint


# S: each class handles one storage provider
# L: each is a drop-in Storage, honoring read/write fully
class S3Storage(CloudStorage):
    def read(self) -> str:
        return f"Reading from S3: {self._endpoint}"

    def write(self, data: str) -> None:
        print(f"Writing to S3: {data}")


class AzureBlobStorage(CloudStorage):
    def read(self) -> str:
        return f"Reading from Azure Blob: {self._endpoint}"

    def write(self, data: str) -> None:
        print(f"Writing to Azure Blob: {data}")


# D: high-level class receives its dependency via the constructor
class DataService:
    def __init__(self, storage: Storage):
        self._storage = storage

    def process_data(self) -> None:
        data = self._storage.read()
        self._storage.write(f"Processed: {data}")


if __name__ == "__main__":
    DataService(S3Storage("s3://bucket")).process_data()
    # Same DataService code, different provider
    DataService(AzureBlobStorage("azure://container")).process_data()

Which principle lets you swap S3 for Azure without touching DataService? Dependency Inversion: DataService depends on the Storage interface and gets a concrete instance injected.

Which principles reduce coupling?#

Of the five, only DIP targets coupling head-on:

Principle Effect on coupling
SRP Indirect: separates concerns, but split classes can still be tightly bound
OCP Some: introduces abstractions, but doesn't say how dependencies are wired
LSP Minimal: makes inheritance safe to rely on
ISP Partial: smaller interfaces mean clients depend on less
DIP Direct: "don't create dependencies, receive them"

Practice#

An EmailService creates a GmailSender inside itself. How do you refactor it? Introduce an EmailSender interface, have GmailSender implement it, and inject an EmailSender through EmailService's constructor.

Key takeaways#

  • High-level code and low-level details should both depend on abstractions.
  • DIP is the principle; dependency injection is the technique.
  • A class that constructs its own collaborators is a DIP red flag.
  • Prefer constructor injection for required dependencies, and don't expose setters for them.
  • DIP is what makes unit testing with mocks straightforward.

Found a mistake? Tell me on LinkedIn ↗

Further reading#