Dependency injection is often introduced together with a framework or container. That can make a simple design idea look like infrastructure: register services, configure scopes, add annotations, and ask a runtime to assemble an object graph.

The underlying technique is smaller. A component receives the collaborators it needs instead of constructing or locating them itself. Another part of the program decides which implementations to supply.

For many applications, ordinary constructors and functions are enough. Manual dependency injection keeps object creation visible, makes required dependencies explicit, and avoids coupling application logic to a container API. A container can still be useful when the object graph becomes large or a framework owns object creation, but it is not a prerequisite for dependency injection.

Separate using a dependency from constructing it

Consider an order service that creates its own repository and email client:

class OrderService:
    def __init__(self):
        self.repository = SqlOrderRepository()
        self.mailer = SmtpMailer()

    def place(self, order):
        self.repository.save(order)
        self.mailer.send_receipt(order)

The service now has several responsibilities hidden inside its constructor. It chooses concrete infrastructure, knows how those objects are created, and cannot be instantiated without creating them.

Dependency injection moves those choices outward:

class OrderService:
    def __init__(self, repository, mailer):
        self.repository = repository
        self.mailer = mailer

    def place(self, order):
        self.repository.save(order)
        self.mailer.send_receipt(order)

OrderService still depends on repository and mailer behaviour. Dependency injection does not remove dependencies; it makes their provisioning explicit.

The code that assembles the application can now choose the implementations:

repository = SqlOrderRepository(database_url)
mailer = SmtpMailer(smtp_config)
orders = OrderService(repository, mailer)

That assembly code is allowed to know concrete classes. The application service no longer needs to.

Put assembly at a composition root

If every class stops constructing collaborators, something still has to create the object graph. Keep that responsibility near an application entry point.

For a small command-line program, the composition root might be a function called from main:

def build_application(config):
    repository = SqlOrderRepository(config.database_url)
    mailer = SmtpMailer(config.smtp)
    return OrderService(repository, mailer)


def main():
    config = load_config()
    application = build_application(config)
    run(application)

The exact name is unimportant. The boundary is what matters: construction and configuration happen together, while application behaviour receives ready-to-use collaborators.

A web framework may provide a different entry point, such as an application factory or startup hook. The same principle applies. Keep framework-specific wiring close to the edge instead of letting dependency resolution spread through domain and application code.

Construction code is allowed to be concrete

Trying to remove every reference to a concrete implementation usually creates unnecessary indirection.

The composition root must eventually make decisions such as:

OrderRepository -> SqlOrderRepository
Mailer          -> SmtpMailer
Clock           -> SystemClock

Those decisions are configuration. Keeping them together makes the application’s runtime structure easier to inspect.

The goal is not “no concrete classes.” The goal is to keep construction policy out of code whose job is to perform application behaviour.

Prefer constructor injection for required collaborators

A required dependency should usually be required when the object is created:

class InvoiceService:
    def __init__(self, repository, tax_calculator):
        self.repository = repository
        self.tax_calculator = tax_calculator

This has a useful property: an InvoiceService cannot exist through its normal constructor without both collaborators being supplied.

Setter-style injection weakens that property:

service = InvoiceService()
service.repository = repository
service.tax_calculator = tax_calculator

There is now a period in which service exists but is not ready to work. Callers must know which assignments are mandatory and in what order.

Setter injection can make sense for genuinely optional behaviour or APIs whose lifecycle requires post-construction configuration. It is a poor default for dependencies that every valid instance needs.

Use method parameters for per-operation dependencies

Not every collaborator belongs in object state.

If a dependency varies for each operation, pass it to that operation:

class ReportExporter:
    def export(self, report, destination):
        destination.write(render(report))

A destination chosen by the caller for each export is different from a repository that the exporter uses throughout its lifetime.

Constructor injection communicates object-level requirements. Method parameters communicate call-level requirements. Matching the lifetime of the dependency to the lifetime of its use keeps interfaces smaller.

Depend on the behaviour you need

Dependency injection is often paired with interfaces, abstract base classes, protocols, or other explicit abstractions. Those tools can be valuable, but injecting a collaborator does not automatically require creating a new interface type.

In a dynamically typed language, a small component can depend on the operations it calls:

class ReminderService:
    def __init__(self, sender):
        self.sender = sender

    def remind(self, address, message):
        self.sender.send(address, message)

Tests can supply another object with a compatible send method.

In a statically typed language, an interface may make that contract explicit at compile time. Even there, create abstractions around meaningful application boundaries rather than mechanically adding an interface for every class.

An abstraction earns its place when callers benefit from depending on a stable behaviour while implementations can vary independently.

Testing becomes ordinary object construction

Explicit dependencies make focused tests straightforward because tests can construct the subject with controlled collaborators.

class RecordingMailer:
    def __init__(self):
        self.messages = []

    def send_receipt(self, order):
        self.messages.append(order)


class InMemoryOrderRepository:
    def __init__(self):
        self.saved = []

    def save(self, order):
        self.saved.append(order)


repository = InMemoryOrderRepository()
mailer = RecordingMailer()
service = OrderService(repository, mailer)

order = {"id": "order-123"}
service.place(order)

assert repository.saved == [order]
assert mailer.messages == [order]

These test collaborators are simple because the production service asks only for the behaviour it needs.

Dependency injection does not mean every dependency should be mocked. Real value objects, pure functions, and cheap deterministic collaborators are often better used directly. Replace a dependency in a test when controlling or observing that boundary improves the test.

Avoid turning a container into a service locator

A dependency injection container can assemble objects without application classes knowing about it. Problems begin when application code reaches into the container to obtain collaborators:

class OrderService:
    def __init__(self, services):
        self.services = services

    def place(self, order):
        repository = self.services.resolve("order_repository")
        mailer = self.services.resolve("mailer")

The constructor says that OrderService needs one thing called services, but the implementation actually has several hidden requirements. A caller cannot see the real dependency list without reading method bodies.

This is service-location style: the component asks a registry or locator for what it needs. It may decouple the component from concrete implementations, but it couples the component to the lookup mechanism and hides its actual collaborators.

Prefer resolving the graph at the composition root and passing specific dependencies inward:

repository = container.resolve("order_repository")
mailer = container.resolve("mailer")

orders = OrderService(repository, mailer)

If a framework or container can construct OrderService directly from its declared dependencies, even better. The important boundary is that business code receives collaborators instead of querying the container during normal execution.

Keep configuration values explicit too

Dependencies are not limited to service objects. A component may also depend on configuration such as a timeout, retry limit, or feature policy.

Avoid making application code read process-wide configuration implicitly:

class ApiClient:
    def request(self):
        timeout = int(os.environ["API_TIMEOUT_SECONDS"])

Reading environment variables is an infrastructure concern. Parse and validate configuration near startup, then pass the value or a configuration object inward:

class ApiClient:
    def __init__(self, timeout_seconds):
        if timeout_seconds <= 0:
            raise ValueError("timeout_seconds must be positive")

        self.timeout_seconds = timeout_seconds

This makes configuration requirements visible and prevents tests from having to mutate global process state merely to construct the component.

Do not pass one enormous configuration object everywhere. Give a component the settings it actually needs, or a cohesive configuration object for a genuine subsystem.

Treat clocks and randomness as dependencies when behaviour depends on them

Code that reads the current time or generates randomness can become difficult to test when those values affect decisions.

Instead of hiding time access deep inside a policy:

from datetime import datetime, timezone


def is_expired(session):
    return session.expires_at <= datetime.now(timezone.utc)

make the varying value explicit:

def is_expired(session, now):
    return session.expires_at <= now

The outer layer can obtain the current time once and pass it in.

A clock object is another reasonable design when many operations need time. The same idea applies to random-number generation: inject or pass the source when deterministic control is part of the requirement.

Do not abstract every call to the standard library preemptively. Introduce the dependency boundary when nondeterminism, side effects, or replacement needs materially affect the design.

Watch for constructor overgrowth

Constructor injection can expose a design problem that was previously hidden.

A class with twelve required collaborators is not necessarily evidence that dependency injection has failed. It may be evidence that the class coordinates too many responsibilities.

Do not solve constructor overgrowth by replacing explicit parameters with a generic container:

class Workflow:
    def __init__(self, services):
        self.services = services

That shortens the signature without reducing the real coupling.

Instead, examine whether some collaborators form a cohesive subsystem, whether orchestration can be split into stages, or whether the class is operating at the wrong abstraction level.

Sometimes a large constructor is justified for an application-level coordinator. Treat it as a design signal, not an automatic rule violation.

Avoid dependency chains passed through uninterested objects

A component should receive dependencies it uses, not dependencies needed only by a distant child.

This is a smell:

Application
  passes database to Controller
    passes database to Service
      passes database to Repository

if only the repository actually uses the database connection.

Construct the repository at the composition root and inject the repository into the service. Then inject the service into the controller.

composition root
  database -> Repository
  Repository -> Service
  Service -> Controller

Each component declares its direct collaborators. The composition root understands the whole graph.

Manage resource lifetimes at the boundary

Some dependencies own resources that need explicit cleanup: database connections, file handles, network clients, executors, or background workers.

Manual dependency injection does not remove lifecycle management. It makes the owner responsible for it.

For example:

def main():
    database = Database(database_url)

    try:
        repository = SqlOrderRepository(database)
        service = OrderService(repository, SmtpMailer(smtp_config))
        run(service)
    finally:
        database.close()

In real applications, context managers or framework lifecycle hooks may provide cleaner ownership. The key is to align construction and cleanup.

A long-lived singleton, a per-request object, and a per-operation resource have different lifetimes. If manual wiring starts making those lifetimes difficult to enforce, a framework or container with well-defined scope management may be useful.

Know when a container earns its complexity

Manual injection works especially well when:

  • the object graph is small or moderate;
  • construction happens in a few entry points;
  • dependency lifetimes are straightforward;
  • explicit wiring remains easy to read;
  • the framework does not require container-managed creation.

A container becomes more attractive when a large application has repetitive graph assembly, framework-managed components, many scoped lifetimes, or modular registration requirements.

Even then, keep the container near the composition root. Application classes should normally remain unaware of how their collaborators were resolved.

The container is an assembly tool, not a replacement for explicit dependency design.

Common pitfalls

Injecting everything

Values and collaborators that are cheap, deterministic, and intrinsic to an implementation do not automatically need injection. Extra seams increase interface complexity.

Creating an interface for every class

Dependency injection and interface proliferation are separate decisions. Introduce abstractions where a stable behavioural boundary is useful.

Hiding dependencies behind a locator

Passing a generic resolver makes constructors shorter but moves the real dependency list into runtime lookup calls.

Mixing construction with application behaviour

Factories and composition roots should assemble objects. Domain and application services should focus on their behaviour.

Ignoring lifecycle ownership

Whoever creates a resource-bearing dependency must ensure that some clear owner closes or releases it at the correct time.

Using dependency injection to excuse oversized classes

A long dependency list can reveal excessive responsibility. Do not hide the signal with a container or parameter bag before examining the design.

Keep the wiring boring

Good dependency injection is usually uneventful. A class states what it needs, an outer layer constructs those collaborators, and the class uses them without knowing where they came from.

Start with constructor injection for required object-level dependencies and method parameters for values that vary per operation. Keep concrete construction at a small composition root. Introduce a container only when it solves an actual assembly or lifecycle problem, and keep container lookup out of application logic.

The result is not dependency-free software. It is software whose dependencies are visible at the boundaries where developers can reason about, replace, test, and manage them.