Architecture Pattern

Dependency Injection

Provide objects with their dependencies from outside rather than constructing them internally.

Dependency Injection: A Complete Guide with Examples in 11 Programming Languages

Dependency Injection — DI — is a design pattern in which an object receives its dependencies from an external source rather than creating them itself. Instead of a class constructing the services it needs, those services are provided to it — injected from outside at the moment the class is created or used. The class declares what it needs; something else decides what to provide.

The concept was formalized by Martin Fowler in 2004 as a specific form of the broader Inversion of Control (IoC) principle. Where IoC says "don't call us, we'll call you," DI makes that concrete: the framework or caller is responsible for constructing dependencies and delivering them to the class that needs them. The class is no longer in charge of its own supply chain.

In this comprehensive guide, we'll explore Dependency Injection with real-world examples in Python, TypeScript, Java, JavaScript, C#, PHP, Go, Rust, Dart, Swift, and Kotlin — complete with language-specific best practices, common pitfalls, and the techniques that produce genuinely testable, maintainable systems.

Table of Contents


What is Dependency Injection?

A dependency is any object that a class requires in order to function — a database connection, an email sender, a logger, a payment gateway, a clock. The naive approach is for the class to create these dependencies itself:

class OrderService:
    def __init__(self):
        self.db      = PostgresDatabase("postgres://...")  # created internally
        self.mailer  = SmtpMailer("smtp.example.com")      # created internally
        self.logger  = FileLogger("/var/log/orders.log")   # created internally

This looks harmless but creates deep coupling. OrderService is permanently bound to PostgresDatabase, SmtpMailer, and FileLogger. In a test, you cannot substitute a fast in-memory database for the Postgres instance. You cannot redirect emails to a test double. You cannot capture log output. The class is untestable in isolation.

Dependency Injection resolves this by inverting the responsibility. The class declares what it needs; the caller provides it:

class OrderService:
    def __init__(self, db: Database, mailer: Mailer, logger: Logger):
        self.db     = db       # provided externally
        self.mailer = mailer   # provided externally
        self.logger = logger   # provided externally

Now OrderService is decoupled from every specific implementation. In production, the caller provides PostgresDatabase, SmtpMailer, FileLogger. In tests, the caller provides InMemoryDatabase, FakeMailer, NullLogger. The class itself is unchanged. The class does its job; the caller decides how the job is supported.

This is the entirety of Dependency Injection at its core: don't create your dependencies — receive them.


Why Use Dependency Injection?

Dependency Injection produces concrete, measurable benefits across the development lifecycle:

  1. Testability: When a class receives its dependencies, tests can substitute fast, controllable test doubles for slow, external, or unpredictable ones. A service that creates its own database is impossible to unit test. A service that accepts a database interface can be tested with an in-memory implementation in milliseconds.

  2. Loose coupling: A class that depends on an interface, not a concrete type, can work with any implementation of that interface. Swapping PostgreSQL for SQLite, SMTP for SendGrid, or a file logger for a cloud logger requires changing the injection site — not the class itself.

  3. Single Responsibility: A class that creates its own dependencies is responsible both for its core logic and for the construction and lifecycle of its supporting objects. DI extracts the construction responsibility, leaving the class responsible only for its core logic.

  4. Open/Closed Principle: New implementations of a dependency can be introduced without modifying the class that uses the dependency. A new payment provider, a new notification channel, a new storage backend — none require editing the business logic class.

  5. Parallel development: When interfaces are defined first, frontend and backend teams (or service teams) can develop independently. One team implements the interface; the other develops against it with a stub.

  6. Configuration flexibility: Which implementations are wired together can be determined by configuration, environment, or a DI container — without changing business logic code. The production environment wires real implementations; the staging environment wires staging implementations; the test environment wires test doubles.

  7. Explicit dependencies: A class with constructor injection has a visible list of everything it needs. This is documentation that the compiler enforces — adding a new dependency requires updating the constructor, which updates every call site, which makes hidden coupling visible.


Dependency Injection Comparison

ApproachWho Creates DependenciesTestabilityCouplingFlexibility
No DI (hardcoded)The class itselfPoor — cannot substituteTightNone
Service LocatorClass pulls from a registryLimited — registry is global stateModerateModerate
Manual DICaller constructs and injectsExcellentLooseFull
DI Container / FrameworkContainer wires automaticallyExcellentLooseFull + automatic
Factory MethodFactory constructs for callerGood — factory can be mockedModerateModerate

DI vs. Service Locator: Both patterns address the same coupling problem, but differently. A Service Locator is a global registry that classes query to get their dependencies: db = ServiceLocator.get(Database). The class is still responsible for obtaining its dependencies — it just asks the locator instead of constructing them. Dependencies are hidden inside method bodies rather than declared in constructors. DI makes dependencies explicit in the constructor signature; Service Locator keeps them implicit. DI is almost always preferred for this reason — it is testable, readable, and compiler-verifiable.

DI vs. Factory Method: A factory creates objects but the consumer calls the factory. With DI, the consumer never calls a factory — the dependency arrives pre-built. Factories are useful when dependencies have complex construction or when the specific type is determined at runtime. DI and factories often work together: a DI container may use factories internally to construct complex dependencies.

Manual DI vs. DI Container: Manual DI means the caller (main function, test, or composition root) constructs dependencies and passes them explicitly. DI containers (Spring, .NET's built-in DI, Koin, Dagger) automate this wiring based on configuration or annotations. Manual DI is simpler and more transparent; containers reduce boilerplate for large dependency graphs. Both are valid DI — the choice is about scale and preference, not correctness.

DI vs. Global Variables: A global singleton database connection is a hidden dependency — every class that reads it depends on it without declaring the dependency. DI makes all dependencies explicit. Global state is a DI violation because it is a dependency that the class never declares and the caller never controls.


Dependency Injection Explained

The Three Forms of Injection

Constructor Injection — dependencies are provided as constructor parameters. This is the most common and generally recommended form. Dependencies are required, visible, immutable after construction, and declared at the point where the class is created.

class OrderService:
    def __init__(self, db: Database, mailer: Mailer):
        self._db     = db
        self._mailer = mailer

Setter Injection (Property Injection) — dependencies are provided via setter methods after construction. Useful for optional dependencies or when circular dependencies prevent constructor injection. Drawback: the object is in an incomplete state between construction and setter calls.

class OrderService:
    def set_logger(self, logger: Logger) -> None:
        self._logger = logger

Method/Interface Injection — the dependency is passed as a parameter to the specific method that uses it. Used for dependencies that vary per call rather than per object.

class OrderService:
    def process_order(self, order: Order, payment: PaymentGateway) -> Receipt:
        return payment.charge(order.total)

The Composition Root

A key DI concept is the Composition Root — a single location in the application where all dependencies are wired together. This is usually the application entry point (main, startup, or an application module). The Composition Root is the only place in the codebase that is allowed to know about concrete types. All other code depends only on interfaces.

main.py (Composition Root)
├── db      = PostgresDatabase(connection_string)
├── mailer  = SmtpMailer(smtp_config)
├── logger  = CloudLogger(api_key)
└── service = OrderService(db, mailer, logger)

Everything inside OrderService knows only about Database, Mailer, and Logger interfaces. Only main.py knows about PostgresDatabase, SmtpMailer, and CloudLogger.

Dependency Inversion Principle (DIP)

DI is the mechanism for implementing the Dependency Inversion Principle (the D in SOLID): high-level modules should not depend on low-level modules; both should depend on abstractions. OrderService (high-level) should not depend on PostgresDatabase (low-level). Both should depend on Database (abstraction). DI is the structural pattern that enforces DIP at runtime.

DI Containers

For large applications with complex dependency graphs, a DI container automates the construction and wiring of dependencies. The container maintains a registry of types, their dependencies, and their lifetimes. When a type is requested, the container constructs it and all its transitive dependencies automatically.

Lifetime management is a core container concern:

  • Transient: A new instance is created every time the dependency is requested
  • Scoped: One instance per request, session, or unit of work
  • Singleton: One instance for the entire application lifetime

Class Diagram


Beginner-Friendly Example

The clearest DI illustration: a notification service that sends user alerts. Without DI, the service hardcodes its message sender and makes it untestable. With DI, the sender is injected — tests can verify messages without sending real emails or SMS.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
import smtplib
from email.message import EmailMessage


# ════════════════════════════════════════════════════════════════
# WITHOUT DI — Hardcoded dependency, impossible to unit test
# ════════════════════════════════════════════════════════════════

class NotificationServiceNoDI:
    def __init__(self) -> None:
        # Hardcoded: impossible to swap in tests without monkey-patching
        self._smtp_host = "smtp.example.com"
        self._smtp_port = 587

    def notify_user(self, user_email: str, message: str) -> None:
        # Creates the dependency internally — caller has no control
        msg = EmailMessage()
        msg["To"]      = user_email
        msg["Subject"] = "Notification"
        msg.set_content(message)
        with smtplib.SMTP(self._smtp_host, self._smtp_port) as smtp:
            smtp.send_message(msg)
        # Test for this? You'd send a real email — or patch smtplib globally.


# ════════════════════════════════════════════════════════════════
# WITH DI — Interface + injection; testable, swappable, clear
# ════════════════════════════════════════════════════════════════

# ── Step 1: Define the interface (abstraction) ─────────────────
class MessageSender(ABC):
    """Contract for anything that can send a message to a user."""

    @abstractmethod
    def send(self, recipient: str, subject: str, body: str) -> None: ...


# ── Step 2: Real implementations ──────────────────────────────
class SmtpEmailSender(MessageSender):
    """Production: sends real emails via SMTP."""

    def __init__(self, host: str, port: int, username: str, password: str) -> None:
        self._host     = host
        self._port     = port
        self._username = username
        self._password = password

    def send(self, recipient: str, subject: str, body: str) -> None:
        msg = EmailMessage()
        msg["From"]    = self._username
        msg["To"]      = recipient
        msg["Subject"] = subject
        msg.set_content(body)
        with smtplib.SMTP(self._host, self._port) as smtp:
            smtp.starttls()
            smtp.login(self._username, self._password)
            smtp.send_message(msg)


class ConsoleSender(MessageSender):
    """Development: prints to stdout instead of sending real messages."""

    def send(self, recipient: str, subject: str, body: str) -> None:
        print(f"[CONSOLE] To: {recipient} | Subject: {subject}\n{body}")


# ── Step 3: Test doubles ───────────────────────────────────────
@dataclass
class SentMessage:
    recipient: str
    subject:   str
    body:      str


class FakeSender(MessageSender):
    """Test double: captures sent messages for assertion — no real network."""

    def __init__(self) -> None:
        self.sent: list[SentMessage] = []

    def send(self, recipient: str, subject: str, body: str) -> None:
        self.sent.append(SentMessage(recipient, subject, body))

    @property
    def last_sent(self) -> Optional[SentMessage]:
        return self.sent[-1] if self.sent else None


# ── Step 4: The service — depends on the abstraction, not the concrete type ──
class NotificationService:
    """Sends user notifications via whatever MessageSender is injected."""

    def __init__(self, sender: MessageSender) -> None:
        self._sender = sender   # ← injected, not created

    def notify_user(self, user_email: str, message: str) -> None:
        self._sender.send(
            recipient=user_email,
            subject="You have a new notification",
            body=message,
        )

    def notify_order_shipped(self, user_email: str, order_id: str) -> None:
        self._sender.send(
            recipient=user_email,
            subject=f"Your order {order_id} has shipped!",
            body=f"Great news! Order {order_id} is on its way.",
        )


# ── Composition Root (main / startup) ─────────────────────────
def main() -> None:
    import os

    # Production wiring: inject real SMTP sender
    production_sender = SmtpEmailSender(
        host=os.getenv("SMTP_HOST", "smtp.example.com"),
        port=int(os.getenv("SMTP_PORT", "587")),
        username=os.getenv("SMTP_USER", ""),
        password=os.getenv("SMTP_PASS", ""),
    )
    production_service = NotificationService(production_sender)

    # Development wiring: inject console sender
    dev_service = NotificationService(ConsoleSender())
    dev_service.notify_user("jane@example.com", "Welcome to the platform!")
    dev_service.notify_order_shipped("jane@example.com", "ORD-001")


# ── Unit test (no real network, no monkey-patching) ────────────
def test_notify_user_sends_correct_email() -> None:
    fake_sender = FakeSender()
    service     = NotificationService(fake_sender)

    service.notify_user("jane@example.com", "Hello!")

    assert len(fake_sender.sent) == 1
    msg = fake_sender.last_sent
    assert msg.recipient == "jane@example.com"
    assert msg.subject   == "You have a new notification"
    assert "Hello!" in msg.body
    print("✓ test_notify_user_sends_correct_email passed")


def test_notify_order_shipped_includes_order_id() -> None:
    fake_sender = FakeSender()
    service     = NotificationService(fake_sender)

    service.notify_order_shipped("jane@example.com", "ORD-42")

    msg = fake_sender.last_sent
    assert "ORD-42" in msg.subject
    assert "ORD-42" in msg.body
    print("✓ test_notify_order_shipped_includes_order_id passed")


if __name__ == "__main__":
    test_notify_user_sends_correct_email()
    test_notify_order_shipped_includes_order_id()
    main()

Production-Ready Example

The most instructive DI challenge in production: an order processing service with multiple dependencies — a database, a payment gateway, an email sender, and a logger. DI makes each dependency swappable, the service fully testable, and the composition explicit.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Optional
from datetime import datetime
import logging
import uuid


# ════════════════════════════════════════════════════════════════
# Domain model
# ════════════════════════════════════════════════════════════════

@dataclass
class Order:
    order_id:    str
    customer_id: str
    amount:      Decimal
    status:      str = "pending"
    created_at:  datetime = field(default_factory=datetime.utcnow)


@dataclass
class PaymentResult:
    success:        bool
    transaction_id: Optional[str]
    error_message:  Optional[str]


# ════════════════════════════════════════════════════════════════
# Abstractions (interfaces)
# ════════════════════════════════════════════════════════════════

class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: Order) -> None: ...
    @abstractmethod
    def find_by_id(self, order_id: str) -> Optional[Order]: ...
    @abstractmethod
    def update_status(self, order_id: str, status: str) -> None: ...


class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, order_id: str, amount: Decimal, customer_id: str) -> PaymentResult: ...


class Notifier(ABC):
    @abstractmethod
    def send_order_confirmation(self, customer_id: str, order: Order) -> None: ...
    @abstractmethod
    def send_payment_failure(self, customer_id: str, order: Order, reason: str) -> None: ...


class AppLogger(ABC):
    @abstractmethod
    def info(self, message: str, **context) -> None: ...
    @abstractmethod
    def error(self, message: str, **context) -> None: ...


# ════════════════════════════════════════════════════════════════
# Production implementations
# ════════════════════════════════════════════════════════════════

class PostgresOrderRepository(OrderRepository):
    def __init__(self, connection_string: str) -> None:
        self._conn_str = connection_string
        # Real: psycopg2 / asyncpg connection pool

    def save(self, order: Order) -> None:
        print(f"[DB] INSERT order {order.order_id}")

    def find_by_id(self, order_id: str) -> Optional[Order]:
        print(f"[DB] SELECT order {order_id}")
        return None

    def update_status(self, order_id: str, status: str) -> None:
        print(f"[DB] UPDATE order {order_id}{status}")


class StripePaymentGateway(PaymentGateway):
    def __init__(self, api_key: str) -> None:
        self._api_key = api_key
        # Real: import stripe; stripe.api_key = api_key

    def charge(self, order_id: str, amount: Decimal, customer_id: str) -> PaymentResult:
        print(f"[Stripe] Charging ${amount} for order {order_id}")
        # Real: stripe.PaymentIntent.create(...)
        return PaymentResult(success=True, transaction_id=f"pi_{uuid.uuid4().hex[:8]}", error_message=None)


class EmailNotifier(Notifier):
    def __init__(self, smtp_host: str, smtp_port: int, from_address: str) -> None:
        self._smtp_host   = smtp_host
        self._smtp_port   = smtp_port
        self._from_address = from_address

    def send_order_confirmation(self, customer_id: str, order: Order) -> None:
        print(f"[Email] Confirmation → {customer_id}: Order {order.order_id} confirmed")

    def send_payment_failure(self, customer_id: str, order: Order, reason: str) -> None:
        print(f"[Email] Failure → {customer_id}: Order {order.order_id} failed: {reason}")


class StructuredLogger(AppLogger):
    def __init__(self, name: str) -> None:
        self._log = logging.getLogger(name)

    def info(self, message: str, **context) -> None:
        self._log.info(message, extra=context)
        print(f"[INFO] {message} {context}")

    def error(self, message: str, **context) -> None:
        self._log.error(message, extra=context)
        print(f"[ERROR] {message} {context}")


# ════════════════════════════════════════════════════════════════
# Test doubles
# ════════════════════════════════════════════════════════════════

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

    def save(self, order: Order) -> None:
        self._store[order.order_id] = order

    def find_by_id(self, order_id: str) -> Optional[Order]:
        return self._store.get(order_id)

    def update_status(self, order_id: str, status: str) -> None:
        if order := self._store.get(order_id):
            order.status = status


class FakePaymentGateway(PaymentGateway):
    def __init__(self, *, should_succeed: bool = True) -> None:
        self.charged:        list[dict] = []
        self._should_succeed = should_succeed

    def charge(self, order_id: str, amount: Decimal, customer_id: str) -> PaymentResult:
        self.charged.append({"order_id": order_id, "amount": amount, "customer_id": customer_id})
        if self._should_succeed:
            return PaymentResult(True, f"txn_{order_id}", None)
        return PaymentResult(False, None, "Card declined")


class FakeNotifier(Notifier):
    def __init__(self) -> None:
        self.confirmations_sent: list[str] = []
        self.failures_sent:      list[str] = []

    def send_order_confirmation(self, customer_id: str, order: Order) -> None:
        self.confirmations_sent.append(order.order_id)

    def send_payment_failure(self, customer_id: str, order: Order, reason: str) -> None:
        self.failures_sent.append(order.order_id)


class NullLogger(AppLogger):
    def info(self, message: str, **context) -> None: pass
    def error(self, message: str, **context) -> None: pass


# ════════════════════════════════════════════════════════════════
# OrderService — depends entirely on abstractions, never on concrete types
# ════════════════════════════════════════════════════════════════

class OrderService:
    """
    Processes orders: persist → charge → notify.

    All dependencies are injected via constructor. The service has no knowledge
    of Postgres, Stripe, or SMTP. In tests, each can be replaced with a fast
    in-memory test double.
    """

    def __init__(
        self,
        repository: OrderRepository,
        payment:    PaymentGateway,
        notifier:   Notifier,
        logger:     AppLogger,
    ) -> None:
        self._repo     = repository
        self._payment  = payment
        self._notifier = notifier
        self._logger   = logger

    def place_order(self, customer_id: str, amount: Decimal) -> Order:
        """Creates an order, charges the customer, and sends a confirmation."""
        order = Order(
            order_id=str(uuid.uuid4()),
            customer_id=customer_id,
            amount=amount,
        )
        self._repo.save(order)
        self._logger.info("Order created", order_id=order.order_id, customer_id=customer_id)

        result = self._payment.charge(order.order_id, amount, customer_id)

        if result.success:
            self._repo.update_status(order.order_id, "confirmed")
            order.status = "confirmed"
            self._notifier.send_order_confirmation(customer_id, order)
            self._logger.info("Order confirmed", order_id=order.order_id, txn=result.transaction_id)
        else:
            self._repo.update_status(order.order_id, "payment_failed")
            order.status = "payment_failed"
            self._notifier.send_payment_failure(customer_id, order, result.error_message or "Unknown error")
            self._logger.error("Payment failed", order_id=order.order_id, reason=result.error_message)

        return order


# ════════════════════════════════════════════════════════════════
# Tests — fast, isolated, no external services
# ════════════════════════════════════════════════════════════════

def test_place_order_success() -> None:
    # Arrange — inject test doubles
    repo     = InMemoryOrderRepository()
    payment  = FakePaymentGateway(should_succeed=True)
    notifier = FakeNotifier()
    logger   = NullLogger()
    service  = OrderService(repo, payment, notifier, logger)

    # Act
    order = service.place_order("CUST-001", Decimal("99.99"))

    # Assert
    assert order.status == "confirmed"
    assert len(payment.charged) == 1
    assert payment.charged[0]["amount"] == Decimal("99.99")
    assert order.order_id in notifier.confirmations_sent
    saved = repo.find_by_id(order.order_id)
    assert saved is not None and saved.status == "confirmed"
    print("✓ test_place_order_success passed")


def test_place_order_payment_failure() -> None:
    repo     = InMemoryOrderRepository()
    payment  = FakePaymentGateway(should_succeed=False)
    notifier = FakeNotifier()
    service  = OrderService(repo, payment, notifier, NullLogger())

    order = service.place_order("CUST-002", Decimal("49.99"))

    assert order.status == "payment_failed"
    assert order.order_id in notifier.failures_sent
    assert order.order_id not in notifier.confirmations_sent
    print("✓ test_place_order_payment_failure passed")


# ════════════════════════════════════════════════════════════════
# Composition Root — only place that knows about concrete types
# ════════════════════════════════════════════════════════════════

def create_production_service() -> OrderService:
    import os
    return OrderService(
        repository=PostgresOrderRepository(os.getenv("DATABASE_URL", "")),
        payment=StripePaymentGateway(os.getenv("STRIPE_KEY", "")),
        notifier=EmailNotifier(
            smtp_host=os.getenv("SMTP_HOST", "smtp.example.com"),
            smtp_port=int(os.getenv("SMTP_PORT", "587")),
            from_address=os.getenv("FROM_EMAIL", "noreply@example.com"),
        ),
        logger=StructuredLogger("order_service"),
    )


if __name__ == "__main__":
    test_place_order_success()
    test_place_order_payment_failure()
    print("\n[Production wiring]")
    prod_service = create_production_service()
    order = prod_service.place_order("CUST-001", Decimal("99.99"))
    print(f"Order placed: {order.order_id}{order.status}")

Real-World Use Cases

DI applies at every layer of a production system, from individual services to full application wiring.

Web frameworks and request handling: HTTP request handlers depend on services (user service, order service, auth service). With DI, frameworks like Spring Boot, ASP.NET Core, NestJS, and Laravel automatically construct and inject the correct service implementations for each request. In tests, controllers receive injected test doubles without the real HTTP layer.

Database access: A UserRepository interface that is implemented by PostgresUserRepository in production and InMemoryUserRepository in tests enables complete unit testing of all business logic without a database process. The business logic never knows which is running — it only knows the repository interface.

Payment processing: A PaymentGateway interface makes it trivial to test payment flow with a FakePaymentGateway that can simulate success, decline, and network failure without touching Stripe. The real StripeGateway is injected only in production.

Logging and observability: A Logger interface injected into every service allows production to use a structured cloud logger while tests use a NullLogger that discards all output — keeping test output clean without reducing production observability.

Clock injection: Code that depends on the current time can inject a Clock interface. Production injects SystemClock; tests inject FakeClock with a fixed time — making time-dependent logic completely deterministic in tests.

Feature flags: A FeatureFlags interface allows production to query a remote feature flag service while tests inject a StaticFeatureFlags with hardcoded on/off values — enabling reliable testing of all flag-gated code paths.

Email and SMS: Notification services injected with a Notifier interface never know whether they are talking to an SMTP server, a SendGrid API, or a FakeNotifier that captures sent messages for assertion.

Configuration: A ConfigProvider interface injected into services allows tests to provide controlled configuration without environment variables or config files.


Language-Specific Mistakes and Anti-Patterns

❌ Mistake #1: Creating Dependencies Inside __init__

# BAD: Hardcoded dependencies — impossible to substitute in tests
class UserService:
    def __init__(self) -> None:
        self.db     = psycopg2.connect("postgresql://...")   # hardcoded
        self.cache  = redis.Redis(host="localhost")          # hardcoded
        self.mailer = SmtpMailer("smtp.example.com")        # hardcoded

# To test UserService, you need a running PostgreSQL, Redis, and SMTP server.

# GOOD: Accept interfaces; let the caller decide the implementations
class UserService:
    def __init__(self, db: UserRepository, cache: CacheStore, mailer: Mailer) -> None:
        self.db     = db
        self.cache  = cache
        self.mailer = mailer
# Test: UserService(InMemoryRepo(), FakeCache(), FakeMailer()) — no external services

❌ Mistake #2: Using Global Module-Level Singletons as Hidden Dependencies

# BAD: Global singleton accessed directly — hidden dependency, untestable
# database.py
_connection = psycopg2.connect("postgresql://...")

def get_db():
    return _connection

# user_service.py
from database import get_db

class UserService:
    def find_user(self, user_id: str):
        db = get_db()   # ← hidden dependency — can't be substituted without patching
        return db.execute("SELECT * FROM users WHERE id = %s", (user_id,))

# GOOD: Inject the database — the dependency is explicit and swappable
class UserService:
    def __init__(self, db: UserRepository) -> None:
        self._db = db

    def find_user(self, user_id: str):
        return self._db.find_by_id(user_id)

Frequently Asked Questions

Q1: What is the difference between Dependency Injection and Dependency Inversion?

Dependency Inversion (DIP) is a principle — the D in SOLID. It states that high-level modules should not depend on low-level modules; both should depend on abstractions. OrderService should not depend on PostgresDatabase; it should depend on a Database interface.

Dependency Injection is the mechanism that implements DIP at runtime. DI is how you structure code so that OrderService receives a Database interface rather than creating a PostgresDatabase itself. DIP is the why; DI is the how.

You can have DI without DIP (inject a concrete type: OrderService(db: PostgresDatabase) — dependencies are injected but tightly coupled). You cannot effectively achieve DIP without some form of DI (if OrderService creates its own PostgresDatabase, it depends on the concrete type regardless of what interface it declares).

Q2: When should I use a DI container versus manual DI (Pure DI)?

Manual DI — the Composition Root constructs all dependencies explicitly — is appropriate for most applications up to a moderate size. It is simple, transparent, and requires no framework. Every dependency relationship is explicit in code.

A DI container (Spring, .NET Built-in DI, Koin, Dagger, Hilt) is appropriate when the dependency graph becomes complex enough that manual wiring is genuinely burdensome — typically when you have dozens of services with multiple transitive dependencies, when you need automatic lifetime management (scoped per-request, singleton, transient), or when the application architecture requires automatic discovery and registration of implementations.

The common mistake is reaching for a container before it is genuinely needed. Manual DI with a clear Composition Root is simpler and more readable for small to medium applications.

Q3: Is constructor injection always better than setter injection?

Constructor injection is strongly preferred for required dependencies. When a dependency is required for the class to function, the constructor should require it — this makes the dependency's requirement explicit and enforces it at compile time. A class with missing required dependencies cannot be constructed, which fails fast and clearly.

Setter injection is appropriate for optional dependencies — those with sensible defaults that some configurations need to override. It is also sometimes used when circular dependencies prevent constructor injection (though circular dependencies usually indicate a design problem worth solving separately).

Property injection (setting a public property directly) is the weakest form and is generally avoided. The object can be in an incomplete state after construction, and nothing enforces that the property is set before the object is used.

Q4: How does DI interact with third-party libraries that I can't modify?

Third-party libraries that use static methods, global singletons, or constructors without interfaces present a DI challenge. The standard approach is the Adapter Pattern: create a thin wrapper class that implements your interface and delegates to the third-party library. Your business logic depends on your interface; only the adapter knows about the third-party library.

# Third-party library you can't modify:
# stripe.PaymentIntent.create(...)  — static call

# Your adapter:
class StripePaymentGateway(PaymentGateway):
    def charge(self, amount, currency, token):
        # delegates to the third-party static method
        intent = stripe.PaymentIntent.create(amount=amount, currency=currency)
        return PaymentResult(success=True, transaction_id=intent.id)

# Your business logic depends on PaymentGateway, not stripe

The adapter is the only place in the codebase that knows about stripe. Tests inject a FakePaymentGateway instead.

Q5: How do I handle DI in languages without interfaces, like Python or JavaScript?

Python and JavaScript use structural typing (duck typing). An interface is simply an agreed-upon method signature. Any object that has the right methods works as a dependency — no explicit implements declaration required.

In Python, ABC and @abstractmethod can formalize the contract, or a simple protocol (using Protocol from typing) can document the expected interface without enforcement. In JavaScript, the convention is sufficient — document the expected shape in JSDoc or TypeScript's type system.

The DI pattern works identically: inject the dependency; in tests, inject an object with the same methods that returns controlled data.

Q6: Does DI make code harder to follow because you have to trace where dependencies come from?

Initially, yes — the Composition Root pattern means you have to look in one specific place to understand what is wired to what. In a class with constructor injection, you see the interface but not the implementation. This is a trade-off.

In practice, most IDEs provide "Go to Implementation" for interface methods, making tracing straightforward. The benefit — the class is completely readable without knowing which implementation is injected — typically outweighs the navigation cost. A class that creates its own dependencies is both harder to understand (the constructor is full of construction logic) and harder to test.

For readability, the Composition Root should be organized logically — grouping related service registrations, using factory functions with clear names, and structuring the wiring to mirror the application's module structure.

Q7: Can DI be used in functional programming styles?

Yes — DI is not object-oriented by nature. In functional programming, dependencies are passed as function parameters or as partially applied functions (currying). A function that takes a save_order function parameter and uses it instead of calling the database directly is practicing DI.

# Functional DI: dependencies are function parameters
def process_order(
    order: Order,
    charge_payment: Callable[[Decimal], PaymentResult],   # ← injected
    send_confirmation: Callable[[str, Order], None],      # ← injected
) -> Order:
    result = charge_payment(order.amount)
    if result.success:
        send_confirmation(order.customer_id, order)
    return order

# Test: pass fake callables that return controlled results

This is the closest to "pure" DI — no objects or containers needed.

Q8: What is the relationship between DI and unit testing?

DI and unit testing are deeply intertwined. Unit tests test a single unit of code (a class, a function) in isolation from its dependencies. Without DI, a class that creates its own dependencies cannot be tested in isolation — every unit test becomes an integration test that requires real databases, real networks, and real third-party services.

With DI, every dependency can be replaced with a test double: a FakeRepository that stores in memory, a FakeMailer that records sent messages, a FakePaymentGateway that returns controlled results. Each test controls the behavior of every dependency, making tests fast, deterministic, and independent of external services.

The corollary: if a class is difficult to unit test, it usually has a DI problem. The test difficulty is the symptom; the hidden dependency is the cause.

Q9: What are the downsides of DI?

DI has genuine costs:

Indirection: Following the code path from a call site to the implementation requires a navigation step through the interface. In small systems, this is unnecessary complexity.

Constructor length: Classes with many dependencies have long constructors. Long constructors are a code smell that usually means the class has too many responsibilities — but the DI itself is not the cause.

DI container magic: Framework-managed DI containers can make dependency resolution opaque. A type error in container configuration may only surface at runtime, not compile time. Over-relying on annotation-based auto-wiring can make the dependency graph difficult to understand.

Overhead: For small scripts or utilities, DI adds structure without corresponding benefit. A command-line tool that runs once is not meaningfully improved by a dependency injection pattern.

Circular dependencies: DI containers must detect and handle circular dependencies (A depends on B which depends on A). Circular dependencies are usually a design problem — DI exposes them rather than causing them.

Q10: How do I structure a Composition Root in a large application?

A large application's Composition Root should be organized by module or feature, not as a single massive registration block. Each module registers its own dependencies, and the application root composes the modules:

# orders/container.py
def register_order_module(db_url: str, stripe_key: str) -> OrderService:
    repo     = PostgresOrderRepository(db_url)
    payment  = StripePaymentGateway(stripe_key)
    notifier = EmailNotifier(smtp_config_from_env())
    logger   = StructuredLogger("orders")
    return OrderService(repo, payment, notifier, logger)

# users/container.py
def register_user_module(db_url: str) -> UserService:
    repo   = PostgresUserRepository(db_url)
    cache  = RedisCache(redis_url_from_env())
    return UserService(repo, cache)

# main.py (Application Composition Root)
import os
order_service = register_order_module(os.getenv("DB_URL"), os.getenv("STRIPE_KEY"))
user_service  = register_user_module(os.getenv("DB_URL"))

Each module's container function is its own composition root, and the application root composes them. This keeps the wiring readable, modular, and easy to adjust per environment.


Key Takeaways

🎯 Core Concept: Dependency Injection is the pattern where a class receives its dependencies from an external source rather than creating them internally. The class declares what it needs (via constructor parameters, typically typed as interfaces or abstractions); the caller or DI container provides the implementations. This inverts the control of dependency construction from the class to the caller — the defining characteristic of Inversion of Control applied to dependencies.

🔑 Key Benefits:

  • Testability: Every dependency can be replaced with a fast, controllable test double — enabling true unit testing without external services
  • Loose coupling: Classes depend on interfaces, not concrete implementations — swapping implementations requires changing only the injection site
  • Single Responsibility: Classes are responsible for their logic, not the construction and lifecycle of their dependencies
  • Explicit dependencies: Constructor injection makes every dependency visible in the class signature — the compiler enforces completeness
  • Configuration flexibility: Which implementations are wired together can be controlled by environment, configuration, or a DI container without touching business logic
  • Parallel development: Teams can develop against interfaces independently, using stubs until the real implementation is ready

🌐 Language-Specific Highlights:

  • Python: Use ABC + @abstractmethod for formal interfaces, or Protocol for structural typing; inject via __init__ parameters; avoid global module-level singletons as hidden dependencies; dataclass fields with injected dependencies are clean and readable
  • TypeScript: Use interface for dependency contracts (not class or abstract class); avoid importing concrete modules directly in business logic — import only interfaces; NestJS, InversifyJS, and tsyringe provide container support for larger applications
  • Java: Always inject via constructor in Spring (@Autowired on constructor, not field); use interface not abstract class for dependency contracts; @Configuration + @Bean methods are the explicit Composition Root in Spring; avoid field injection with @Autowired — it hides dependencies
  • JavaScript: Constructor injection works with plain classes; avoid the Service Locator anti-pattern (global registry that services pull from); inversify provides container support; for React, Context API or Zustand can inject dependencies into components
  • C#: ASP.NET Core's built-in DI container is excellent — register in Program.cs, inject via constructor; use IServiceScope for scoped lifetime control; prefer Scoped for per-request services, Singleton for stateless services, Transient for cheap stateless operations
  • PHP: Laravel's service container and Symfony's DI component are mature and widely used; constructor injection via type-hinted parameters is idiomatic; avoid app()->make() (Service Locator) inside business logic — let the container push, not let the class pull
  • Go: Define interfaces at the point of use (in the consumer package, not the provider package); use constructor functions (NewOrderService(db, mailer)) to make injection explicit; the wire tool by Google can generate DI wiring code; avoid package-level variables as hidden dependencies
  • Rust: Use generic type parameters struct Service<R: Repository, M: Mailer> for compile-time, zero-cost DI; use Box<dyn Trait> for runtime-polymorphic injection when concrete types vary at runtime; Arc<dyn Trait> for shared, thread-safe injected dependencies; the shaku crate provides container support
  • Dart: Abstract classes as interfaces are idiomatic; the get_it package is a widely used service locator (acceptable when used only in the composition root); Flutter's Provider, Riverpod, and injectable + get_it provide full DI for widget trees; inject in const constructors for compile-time wiring
  • Swift: Use protocol for dependency contracts; struct conformances for stateless dependencies, class for stateful; Swinject and Needle provide container support; SwiftUI's @EnvironmentObject and Environment values are the framework's built-in DI mechanism for view hierarchies
  • Kotlin: Constructor injection with val parameters is idiomatic; Koin is lightweight and Kotlin-native for manual DI; Hilt (Android) and Spring (server) provide full container support; fun interface for single-method dependency contracts; avoid companion object factories that hardcode dependencies

📋 When to Use Each Form of Injection:

  • Constructor injection: Required dependencies — the class cannot function without them. Always preferred. The dependency is set once at construction and never changes.
  • Setter injection: Optional dependencies with sensible defaults, or when circular dependencies prevent constructor injection (which itself is a design signal).
  • Method injection: Dependencies that vary per call, not per object — a PaymentGateway passed to a specific charge() call rather than held for the object's lifetime.

⚠️ DI Anti-Patterns to Avoid:

  • Creating dependencies with new inside constructors or methods — the class controls its own supply chain
  • Service Locator — classes pull from a global registry instead of receiving pushed dependencies
  • Singletons accessed directly (static getInstance()) inside business logic — hidden global dependencies
  • Package/module-level global variables used as dependencies — untestable, shared mutable state
  • Field injection (Java/Kotlin @Autowired on fields) — hides dependencies, prevents constructor-based initialization
  • Injecting the DI container itself into business logic — the class becomes a Service Locator

🛠️ Best Practices Across Languages:

  1. Depend on abstractions: Classes should depend on interfaces or abstract types, not concrete implementations
  2. Constructor-inject required dependencies: Everything the class needs to function should be in the constructor
  3. One Composition Root: Only one place in the application (the entry point) should know about and wire concrete types
  4. Keep interfaces focused: A dependency interface with 10 methods is often better split into 2–3 focused interfaces (Interface Segregation Principle)
  5. Name test doubles clearly: FakeMailer, InMemoryRepository, StubPaymentGateway — the name communicates the double's role
  6. Scope lifetimes deliberately: Singleton for stateless, request-scoped for per-request state, transient for cheap, short-lived objects
  7. Avoid injecting the container: Business logic that calls the container is a Service Locator — push dependencies in, don't pull them out

💡 Common Pitfalls:

  • Over-injection: A constructor with eight parameters usually means the class has too many responsibilities — split the class, then each piece needs fewer dependencies
  • Circular dependencies: A → B → A usually means two classes are too tightly coupled; introduce a third abstraction that both depend on
  • Injecting data instead of services: Configuration values, connection strings, and API keys are not services — pass them to the concrete implementation's constructor, not to business logic
  • Testing with the real container: DI container tests that wire the real application and test end-to-end are integration tests, not unit tests; unit tests inject test doubles directly via constructors without a container

🎁 Advanced Techniques:

  • Decorator injection: Wrap a dependency with a decorator at injection time — new CachingRepository(new PostgresRepository()) — adding cross-cutting concerns without modifying either class
  • Factory injection: Inject a factory function or factory object when the concrete type is determined at runtime, or when a new instance is needed per call rather than per object
  • Conditional wiring: DI containers support conditional registration (register MockMailer if environment is test, SmtpMailer otherwise) — enabling environment-specific wiring without code changes
  • Lifetime scoping: Request-scoped services in web applications receive the same instance throughout one request but get a fresh instance for the next — enabling per-request state without global variables
  • Module-based composition: Organize the Composition Root into modules (each module registers its own services) — the application root composes modules, keeping wiring readable at scale

Dependency Injection is the structural foundation of testable, maintainable software. Every class that creates its own dependencies is a class that cannot be tested in isolation, cannot have its behavior changed without modifying its source, and cannot be easily reused in a different context. Every class that receives its dependencies is a class that can be tested with fast, controlled doubles, can be reconfigured by changing the injection site, and can be composed into multiple application contexts without modification.


Want to dive deeper into design patterns and principles? Check out our comprehensive guides on: