# What is Dependency Injection in Programming?

> *Personal Knowledge Sharing: How understanding dependency injection changed the way I structure Python applications*

## Introduction

I remember the first time I looked at a service class I'd written for my multi-tenant POS backend and thought: "why is this so hard to test?" Every time I tried to write a unit test, the service was already wired up to a live PostgreSQL connection, a real Redis client, and a third-party billing API call inside the constructor. To test a single method I had to either mock the entire world or spin up real infrastructure.

That pain pointed me to one root cause — **tight coupling**. My classes were building their own dependencies internally. Once I understood Dependency Injection (DI), I stopped constructing dependencies *inside* classes and started receiving them *from outside*. It sounds like a small shift, but it changes how you design, test, and maintain every layer of a system.

This article covers what DI is, why it matters, how it works in Python without any framework, and how the pattern shows up naturally in FastAPI through its `Depends()` system.

***

## Table of Contents

* [What is a Dependency?](#what-is-a-dependency)
* [The Problem: Tight Coupling](#the-problem-tight-coupling)
* [What is Dependency Injection?](#what-is-dependency-injection)
* [Types of Dependency Injection](#types-of-dependency-injection)
  * [Constructor Injection](#1-constructor-injection)
  * [Setter Injection](#2-setter-injection)
  * [Interface Injection](#3-interface-injection)
* [Python Protocols as Interfaces](#python-protocols-as-interfaces)
* [DI in a FastAPI Service Layer](#di-in-a-fastapi-service-layer)
* [How the Flow Looks at Runtime](#how-the-flow-looks-at-runtime)
* [Testing Becomes Straightforward](#testing-becomes-straightforward)
* [What I Learned](#what-i-learned)

***

## What is a Dependency?

A **dependency** is any external object, service, or resource that a class or function needs to do its job.

Common examples in backend Python work:

* A database session or repository
* An HTTP client calling an external API
* A cache client (Redis)
* A logger
* A configuration object
* A message queue publisher

When a class creates one of these itself, it *owns* the dependency. When it receives one from outside, it *uses* the dependency. Dependency Injection is the practice of providing dependencies from the outside rather than creating them internally.

***

## The Problem: Tight Coupling

Here is a pattern that was common in my early codebase. A `ReportService` that needs a database connection:

```python
import psycopg2

class ReportService:
    def __init__(self):
        # The service builds its own dependency — tight coupling
        self.conn = psycopg2.connect(
            host="localhost",
            dbname="pos_db",
            user="admin",
            password="secret"
        )

    def get_daily_sales(self, date: str) -> list[dict]:
        cursor = self.conn.cursor()
        cursor.execute(
            "SELECT item_id, quantity, total FROM sales WHERE sale_date = %s",
            (date,)
        )
        return cursor.fetchall()
```

This looks harmless, but hits three problems immediately:

1. **Untestable in isolation** — instantiating `ReportService` always opens a real database connection. There is no way to supply a fake database in a unit test.
2. **Hard to swap implementations** — if the team moves from `psycopg2` to `asyncpg` or SQLAlchemy, the change is buried *inside* the service class.
3. **Hidden configuration** — connection credentials are hardcoded into the class constructor, which leaks environment concerns into business logic.

{% @mermaid/diagram content="graph LR
A\[ReportService] -->|creates internally| B\[psycopg2.connect]
B --> C\[(PostgreSQL)]

```
style A fill:#EF5350,color:#fff
style B fill:#EF5350,color:#fff
style C fill:#42A5F5,color:#fff" %}
```

The red nodes represent tightly coupled code. You cannot take `ReportService` out of this wiring and use it elsewhere.

***

## What is Dependency Injection?

Dependency Injection means that the dependencies a class needs are **provided to it from outside** — typically through the constructor, a setter method, or a function parameter — rather than created inside.

The class declares *what it needs* without caring *how it was built*.

{% @mermaid/diagram content="graph LR
A\[Caller / Composition Root] -->|creates| B\[DBConnection]
A -->|injects into| C\[ReportService]
C -->|uses| B
B --> D\[(PostgreSQL)]

```
style A fill:#66BB6A,color:#fff
style C fill:#66BB6A,color:#fff
style B fill:#42A5F5,color:#fff
style D fill:#42A5F5,color:#fff" %}
```

The creation of `DBConnection` now belongs to a **composition root** — a single place responsible for wiring the application together. `ReportService` is kept clean of that concern.

***

## Types of Dependency Injection

### 1. Constructor Injection

The most common form. Dependencies are declared as parameters in `__init__`. The caller is forced to supply them — there is no ambiguity about what the class needs.

```python
from typing import Protocol


class SalesRepository(Protocol):
    def fetch_daily_sales(self, date: str) -> list[dict]: ...


class ReportService:
    def __init__(self, repository: SalesRepository) -> None:
        # Receive the dependency, don't build it
        self._repository = repository

    def get_daily_sales(self, date: str) -> list[dict]:
        return self._repository.fetch_daily_sales(date)
```

The `SalesRepository` parameter is a `Protocol` (covered in the next section). `ReportService` does not know or care whether it receives a real PostgreSQL repository or a test double.

***

### 2. Setter Injection

Dependencies are supplied through a dedicated method after the object is constructed. I use this pattern sparingly — only when an optional collaborator can be swapped at runtime, such as plugging in a different logging backend.

```python
import logging


class ReportService:
    def __init__(self, repository: SalesRepository) -> None:
        self._repository = repository
        self._logger = logging.getLogger(__name__)  # sensible default

    def set_logger(self, logger: logging.Logger) -> None:
        # Allow a custom logger to be injected after construction
        self._logger = logger

    def get_daily_sales(self, date: str) -> list[dict]:
        self._logger.info("Fetching daily sales for %s", date)
        return self._repository.fetch_daily_sales(date)
```

The risk with setter injection is that the object can be in an incomplete state between construction and calling the setter. Constructor injection avoids that entirely, which is why I default to it.

***

### 3. Interface Injection

The class advertises a setter contract through an interface (Protocol). Any dependency that wants to inject itself must implement the protocol. This is less common in Python because `Protocol` and duck typing usually handle the contract more cleanly through constructor injection.

I have not found a strong reason to use this form in Python work; constructor injection with `Protocol` covers the same intent.

***

## Python Protocols as Interfaces

Python's `typing.Protocol` is the right tool for defining the *shape* a dependency must have without requiring inheritance. This keeps classes decoupled at the type level while still giving `mypy` enough information to catch mismatches.

Here is the full pattern I use across service layers:

```python
# protocols.py
from typing import Protocol


class SalesRepository(Protocol):
    def fetch_daily_sales(self, date: str) -> list[dict]: ...
    def fetch_item_breakdown(self, item_id: int) -> dict: ...


class CacheClient(Protocol):
    def get(self, key: str) -> str | None: ...
    def set(self, key: str, value: str, ttl: int = 300) -> None: ...
```

```python
# repositories/postgres_sales_repository.py
import psycopg2


class PostgresSalesRepository:
    def __init__(self, conn: psycopg2.extensions.connection) -> None:
        self._conn = conn

    def fetch_daily_sales(self, date: str) -> list[dict]:
        cursor = self._conn.cursor()
        cursor.execute(
            "SELECT item_id, quantity, total FROM sales WHERE sale_date = %s",
            (date,)
        )
        rows = cursor.fetchall()
        return [{"item_id": r[0], "quantity": r[1], "total": r[2]} for r in rows]

    def fetch_item_breakdown(self, item_id: int) -> dict:
        cursor = self._conn.cursor()
        cursor.execute(
            "SELECT item_id, name, category FROM items WHERE id = %s",
            (item_id,)
        )
        row = cursor.fetchone()
        return {"item_id": row[0], "name": row[1], "category": row[2]} if row else {}
```

```python
# services/report_service.py
from protocols import SalesRepository, CacheClient
import json


class ReportService:
    def __init__(
        self,
        repository: SalesRepository,
        cache: CacheClient,
    ) -> None:
        self._repository = repository
        self._cache = cache

    def get_daily_sales(self, date: str) -> list[dict]:
        cache_key = f"daily_sales:{date}"
        cached = self._cache.get(cache_key)

        if cached:
            return json.loads(cached)

        results = self._repository.fetch_daily_sales(date)
        self._cache.set(cache_key, json.dumps(results), ttl=600)
        return results
```

`ReportService` now depends on two abstractions — `SalesRepository` and `CacheClient`. The concrete implementations (`PostgresSalesRepository`, `RedisClient`) are assembled elsewhere and injected in.

***

## DI in a FastAPI Service Layer

FastAPI's `Depends()` function is a first-class DI system. When I moved the POS reporting endpoints into a FastAPI application, the framework took over the role of the composition root — it builds dependencies and injects them into route handlers automatically.

```python
# dependencies.py
from fastapi import Depends
import psycopg2
import redis

from repositories.postgres_sales_repository import PostgresSalesRepository
from services.report_service import ReportService


def get_db_connection() -> psycopg2.extensions.connection:
    conn = psycopg2.connect(
        host="localhost",
        dbname="pos_db",
        user="admin",
        password="secret",
    )
    try:
        yield conn
    finally:
        conn.close()


def get_cache_client() -> redis.Redis:
    client = redis.Redis(host="localhost", port=6379, decode_responses=True)
    try:
        yield client
    finally:
        client.close()


def get_report_service(
    conn=Depends(get_db_connection),
    cache=Depends(get_cache_client),
) -> ReportService:
    repository = PostgresSalesRepository(conn)
    return ReportService(repository=repository, cache=cache)
```

```python
# routers/reports.py
from fastapi import APIRouter, Depends
from dependencies import get_report_service
from services.report_service import ReportService

router = APIRouter(prefix="/reports", tags=["reports"])


@router.get("/daily/{date}")
def daily_sales_report(
    date: str,
    service: ReportService = Depends(get_report_service),
):
    return service.get_daily_sales(date)
```

FastAPI resolves the entire dependency graph in `get_report_service` before the route handler runs. The route handler receives a fully assembled `ReportService` and never sees the database connection or cache client.

***

## How the Flow Looks at Runtime

{% @mermaid/diagram content="sequenceDiagram
participant Client as HTTP Client
participant FastAPI
participant DepGraph as Dependency Graph
participant Repo as PostgresSalesRepository
participant Cache as RedisClient
participant Service as ReportService
participant Router as Route Handler

```
Client->>FastAPI: GET /reports/daily/2026-03-26
FastAPI->>DepGraph: Resolve get_report_service
DepGraph->>Repo: Build PostgresSalesRepository(conn)
DepGraph->>Cache: Build RedisClient
DepGraph->>Service: Build ReportService(repository, cache)
DepGraph-->>FastAPI: ReportService instance ready
FastAPI->>Router: daily_sales_report(date, service)
Router->>Service: service.get_daily_sales("2026-03-26")
Service-->>Router: list[dict]
Router-->>Client: JSON response" %}
```

The route handler never calls `psycopg2.connect` or `redis.Redis(...)` directly. Those decisions live in `dependencies.py`, which is the composition root for this application.

***

## Testing Becomes Straightforward

The clearest payoff from DI is in tests. Because `ReportService` accepts `SalesRepository` and `CacheClient` as constructor parameters, I can pass in test doubles — no mocking frameworks required for the core logic.

```python
# tests/test_report_service.py
import pytest
from services.report_service import ReportService


class InMemorySalesRepository:
    """Test double that satisfies SalesRepository protocol."""

    def __init__(self, data: list[dict]) -> None:
        self._data = data

    def fetch_daily_sales(self, date: str) -> list[dict]:
        return [row for row in self._data if row["date"] == date]

    def fetch_item_breakdown(self, item_id: int) -> dict:
        return {}


class InMemoryCache:
    """Test double that satisfies CacheClient protocol."""

    def __init__(self) -> None:
        self._store: dict[str, str] = {}

    def get(self, key: str) -> str | None:
        return self._store.get(key)

    def set(self, key: str, value: str, ttl: int = 300) -> None:
        self._store[key] = value


def test_get_daily_sales_returns_correct_records():
    seed_data = [
        {"date": "2026-03-26", "item_id": 1, "quantity": 10, "total": 500.0},
        {"date": "2026-03-25", "item_id": 2, "quantity": 5, "total": 250.0},
    ]
    repo = InMemorySalesRepository(data=seed_data)
    cache = InMemoryCache()
    service = ReportService(repository=repo, cache=cache)

    result = service.get_daily_sales("2026-03-26")

    assert len(result) == 1
    assert result[0]["item_id"] == 1


def test_get_daily_sales_uses_cache_on_second_call():
    seed_data = [
        {"date": "2026-03-26", "item_id": 1, "quantity": 10, "total": 500.0},
    ]
    repo = InMemorySalesRepository(data=seed_data)
    cache = InMemoryCache()
    service = ReportService(repository=repo, cache=cache)

    # First call — populates cache
    service.get_daily_sales("2026-03-26")

    # Poison the repository so any direct call would return empty
    repo._data = []

    # Second call — should still return from cache
    result = service.get_daily_sales("2026-03-26")

    assert len(result) == 1
```

No patching, no monkeypatching the module, no `unittest.mock.patch` gymnastics. The test constructs real Python objects, and they work because the interface contract is honoured.

For FastAPI routes, the framework provides `app.dependency_overrides` to swap dependencies during integration tests:

```python
# tests/test_reports_router.py
from fastapi.testclient import TestClient
from main import app
from dependencies import get_report_service
from services.report_service import ReportService


def override_report_service() -> ReportService:
    repo = InMemorySalesRepository(data=[
        {"date": "2026-03-26", "item_id": 1, "quantity": 3, "total": 150.0}
    ])
    cache = InMemoryCache()
    return ReportService(repository=repo, cache=cache)


app.dependency_overrides[get_report_service] = override_report_service
client = TestClient(app)


def test_daily_sales_endpoint():
    response = client.get("/reports/daily/2026-03-26")
    assert response.status_code == 200
    assert len(response.json()) == 1
```

The application never touches a real database or Redis instance during this test run.

***

## Architectural Overview

{% @mermaid/diagram content="graph TB
subgraph "Composition Root"
CR\[dependencies.py]
CR -->|builds| REPO\[PostgresSalesRepository]
CR -->|builds| CACHE\[RedisClient]
CR -->|injects into| SVC\[ReportService]
end

```
subgraph "Business Logic"
    SVC -->|uses protocol| SREPO[SalesRepository Protocol]
    SVC -->|uses protocol| SCACHE[CacheClient Protocol]
end

subgraph "HTTP Layer"
    ROUTER[Route Handler] -->|receives| SVC
end

subgraph "Test Layer"
    TEST[Test Suite] -->|injects| FAKE_REPO[InMemorySalesRepository]
    TEST -->|injects| FAKE_CACHE[InMemoryCache]
    TEST -->|constructs| SVC2[ReportService]
end

REPO -.->|satisfies| SREPO
CACHE -.->|satisfies| SCACHE
FAKE_REPO -.->|satisfies| SREPO
FAKE_CACHE -.->|satisfies| SCACHE

style CR fill:#66BB6A,color:#fff
style SVC fill:#42A5F5,color:#fff
style SVC2 fill:#42A5F5,color:#fff
style ROUTER fill:#AB47BC,color:#fff
style TEST fill:#FF7043,color:#fff" %}
```

The business logic in `ReportService` never points at a concrete implementation. It points at protocols. Both production and test layers satisfy those protocols independently.

***

## What I Learned

After applying DI consistently across my Python projects — from `ansible-inspec`'s FastAPI service layer to the multi-tenant POS reporting backend — a few things became clear:

**Start with constructor injection.** It is explicit and honest. The constructor signature is the public contract — it tells every caller what the class needs to function. I avoided setter injection unless there was a genuine runtime-swapping requirement.

**`typing.Protocol` is the right tool in Python.** I do not use abstract base classes (`abc.ABC`) for this anymore. `Protocol` enables structural subtyping — a class satisfies the protocol if it has the right methods, regardless of inheritance. This keeps the concrete implementations free of framework coupling.

**FastAPI's `Depends()` is DI done right at the framework level.** I did not need to reach for `dependency-injector` or any third-party container. FastAPI's dependency graph handles lifetime management (request-scoped vs. application-scoped), cleanup via `yield`, and test overrides through `dependency_overrides`. That covers the vast majority of what a DI container does, with no additional setup.

**The real win is at test time.** Saying "DI makes code testable" is a cliché — but it is genuinely true. When I stopped constructing dependencies inside classes, test setup dropped from twenty lines of mocking scaffolding to three lines of plain Python object construction.

**DI is not magic wiring.** A DI *container* is optional. Python's standard library and FastAPI's built-in system are more than enough for a service-oriented backend. The valuable idea is the pattern itself: *declare what you need, don't build what you need*.

***

*These patterns come from hands-on experience building FastAPI-based services and the* [*ansible-inspec*](https://github.com/Htunn/ansible-inspec) *project. The code examples reflect structures I have actually used rather than simplified toy examples.*
