Post

Dependency Injection in Plain Language

Dependency injection (DI) is a design pattern that keeps two ideas in balance:

  1. Separation of concerns – a class shouldn’t know the concrete details of the things it depends on.

  2. Inversion of control – instead of a class constructing or looking-up its collaborators, someone else hands those collaborators (“dependencies”) to it.

The result is code that is easier to test, swap out, and extend, because the pieces are loosely coupled.


The Problem Without DI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class EmailSender:
    def send(self, to, subject, body):
        print(f"→ Email to {to} | {subject}\n{body}")


class OrderService:
    # 🔴 Directly *creates* its own EmailSender
    def __init__(self):
        self.email = EmailSender()

    def place_order(self, user_email, items):
        # ...save order...
        self.email.send(user_email,
                        "Order confirmed",
                        f"You bought {', '.join(items)}")

Why this hurts:
OrderService is welded to EmailSender. If you need an SMS sender, a mock for unit tests, or a fancier email provider, you must edit OrderService.


Constructor Injection (Most Common)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class OrderService:
    def __init__(self, notifier):          # dependency comes *from outside*
        self.notifier = notifier

    def place_order(self, user_email, items):
        # ...save order...
        self.notifier.send(user_email,
                           "Order confirmed",
                           f"You bought {', '.join(items)}")


# --- wiring the graph ---
email_sender = EmailSender()
order_service = OrderService(email_sender)   # inject

Now OrderService doesn’t care how messages are sent, only that the object obeys .send().


Swapping Dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DummyNotifier:
    def __init__(self):
        self.outbox = []

    def send(self, to, subject, body):
        self.outbox.append((to, subject, body))


def test_place_order_sends_confirmation():
    dummy = DummyNotifier()
    svc = OrderService(dummy)

    svc.place_order("[email protected]", ["book", "pen"])

    assert dummy.outbox == [
        ("[email protected]", "Order confirmed", "You bought book, pen")
    ]

Because the dependency is injected, unit tests stay in-memory and deterministic.


When Frameworks Help

Many Python frameworks (FastAPI, Django with third-party libraries, or dependency-injector, punq, etc.) include containers that:

  • build a graph of objects and their constructor parameters,

  • resolve and inject them at runtime,

  • optionally provide scopes (per-request, singleton, …).

That removes most of the manual “wiring” code while preserving decoupling.


Key Takeaways

  • DI ≠ a library – it’s a way of structuring code; containers just automate it.

  • Inject via the constructor first; fall back to setter or method injection only when necessary.

  • Favor interfaces (protocols/ABCs) instead of concrete classes for dependencies.

  • Your tests become simpler mocks + assertions rather than heavyweight integration setups.

This post is licensed under CC BY 4.0 by the author.