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 -> SystemClockThose 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_calculatorThis 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_calculatorThere 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_secondsThis 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 <= nowThe 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 = servicesThat 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 Repositoryif 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 -> ControllerEach 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.