Skip to content

Dependency Injection

Dependency Injection is a pattern that implements Dependency Inversion. The principle defines dependency direction. The pattern defines how objects receive dependencies.

Core Idea

A class should receive collaborators from the outside. It should not create them inside business methods. This keeps construction separate from behavior.

Conceptual Overview

Dependency injection gives explicit boundaries. Object creation happens in one assembly point. Business objects focus on domain behavior.

All examples below use the shared handbook application context: Hypothetical Scenario.

Common forms:

  • Constructor injection
  • Method parameter injection
  • Framework managed injection

Dependency injection assembly

Composition root creates objects and wires contracts. Use case code receives ready collaborators.

Dependency injection request flow

Request flow with explicit construction and pass through steps.

Computing History

In 2004, Martin Fowler documented Dependency Injection as a core Inversion of Control pattern. The pattern became common in web frameworks and enterprise systems. Teams adopted it to reduce hidden state and test friction.

Sources: Fowler (2004)

Generic Example: Constructor Injection

from dataclasses import dataclass


class EventPublisher:
    async def publish(self, topic: str, payload: dict) -> None:
        raise NotImplementedError


@dataclass
class CarDealNotifier:
    publisher: EventPublisher

    async def notify_match_score(self, profile_id: str, listing_id: str, score: int) -> None:
        payload = {"profile_id": profile_id, "listing_id": listing_id, "score": score}
        await self.publisher.publish("car.match.score.updated", payload)

CarDealNotifier receives its dependency. It does not construct a broker client.

Generic Example: FastAPI Wiring

from fastapi import Depends, FastAPI

app = FastAPI()


def get_event_publisher() -> EventPublisher:
    return KafkaPublisher(brokers=["broker:9092"])


@app.post("/profiles/{profile_id}/listings/{listing_id}/score")
async def update_match_score(
    profile_id: str,
    listing_id: str,
    score: int,
    publisher: EventPublisher = Depends(get_event_publisher),
):
    use_case = CarDealNotifier(publisher=publisher)
    await use_case.notify_match_score(
        profile_id=profile_id,
        listing_id=listing_id,
        score=score,
    )
    return {"status": "accepted"}

The route receives infrastructure dependency. The use case receives an abstraction instance.

Generic Example: Rust with Traits

Rust traits provide a strong abstraction boundary. The use case depends on a trait contract. Any struct that implements the trait can be injected.

#[derive(Debug)]
struct PublishError;

trait EventPublisher {
    fn publish(&mut self, topic: &str, payload: String) -> Result<(), PublishError>;
}

struct CarDealNotifier<P: EventPublisher> {
    publisher: P,
}

impl<P: EventPublisher> CarDealNotifier<P> {
    fn new(publisher: P) -> Self {
        Self { publisher }
    }

    fn notify_match_score(
        &mut self,
        profile_id: &str,
        listing_id: &str,
        score: u32,
    ) -> Result<(), PublishError> {
        let payload = format!(
            "{{\"profile_id\":\"{}\",\"listing_id\":\"{}\",\"score\":{}}}",
            profile_id, listing_id, score
        );
        self.publisher.publish("car.match.score.updated", payload)
    }
}

Composition root injects a concrete publisher:

struct KafkaPublisher;

impl EventPublisher for KafkaPublisher {
    fn publish(&mut self, _topic: &str, _payload: String) -> Result<(), PublishError> {
        Ok(())
    }
}

fn build_notifier() -> CarDealNotifier<KafkaPublisher> {
    CarDealNotifier::new(KafkaPublisher)
}

Trait object variant for runtime selection:

struct CarDealNotifierDyn {
    publisher: Box<dyn EventPublisher>,
}

impl CarDealNotifierDyn {
    fn new(publisher: Box<dyn EventPublisher>) -> Self {
        Self { publisher }
    }
}

Unit test with a fake implementation:

#[derive(Default)]
struct FakePublisher {
    events: Vec<(String, String)>,
}

impl EventPublisher for FakePublisher {
    fn publish(&mut self, topic: &str, payload: String) -> Result<(), PublishError> {
        self.events.push((topic.to_string(), payload));
        Ok(())
    }
}

#[test]
fn notify_match_score_publishes_one_event() {
    let fake = FakePublisher::default();
    let mut notifier = CarDealNotifier::new(fake);

    let result = notifier.notify_match_score("profile-42", "vin-4Y1SL65848Z411439", 91);

    assert!(result.is_ok());
    assert_eq!(notifier.publisher.events.len(), 1);
    assert_eq!(notifier.publisher.events[0].0, "car.match.score.updated");
}

Core behavior stays unchanged. Only injected implementations change across environments.

Testing with Stable Fakes

class FakePublisher(EventPublisher):
    def __init__(self):
        self.events = []

    async def publish(self, topic: str, payload: dict) -> None:
        self.events.append((topic, payload))


async def test_notify_match_score_publishes_one_event():
    fake = FakePublisher()
    notifier = CarDealNotifier(publisher=fake)

    await notifier.notify_match_score(
        profile_id="profile-42",
        listing_id="vin-4Y1SL65848Z411439",
        score=91,
    )

    assert fake.events == [
        (
            "car.match.score.updated",
            {
                "profile_id": "profile-42",
                "listing_id": "vin-4Y1SL65848Z411439",
                "score": 91,
            },
        )
    ]

The test verifies behavior from input to output. No patching of internals is needed.

Anti Pattern and Better Version

Non compliant:

class CarDealNotifier:
    async def notify_match_score(self, profile_id: str, listing_id: str, score: int) -> None:
        broker = KafkaPublisher(brokers=["broker:9092"])
        await broker.publish(
            "car.match.score.updated",
            {"profile_id": profile_id, "listing_id": listing_id, "score": score},
        )

Issue:

  • Class creates infrastructure detail directly
  • Tests must patch constructors and internal calls

Compliant:

  • Create dependencies in a composition root
  • Inject dependencies through constructor or parameters
  • Keep use case code free of concrete client creation

Dependency injection testability

Testing cost comparison. Injected dependencies keep tests short and stable.

This pattern links to Abstraction and Boundaries. Injection consumes contracts instead of concrete internals.

This pattern links to Modularity and Composition. Composition roots assemble modules through explicit collaborators.

This pattern links to Simplicity First. Construction and behavior stay separated.

This pattern links to Correctness and Testing. Injected fakes keep tests small and deterministic.

Practice Checklist

  • Build concrete objects in one composition root
  • Inject abstractions into use case classes
  • Avoid hidden singletons with mutable global state
  • Keep tests focused on outcomes, not call mechanics
  • Replace mocks with deterministic fakes when possible

Written by: Pedro Guzmán

See References for complete APA-style bibliographic entries used on this page.