Day 054: Dependency Injection and Runtime Composition
Dependency injection becomes useful the moment your business logic stops constructing infrastructure and starts stating, plainly, what it needs to work.
Today's "Aha!" Moment
Dependency injection is often explained through frameworks, containers, annotations, or decorators. That is backwards. Those are tools that may automate wiring later. The actual design idea is simpler: a component should not secretly decide which database client, payment gateway, or notifier it will use. It should declare its collaborators and let something outside the business logic provide them.
Imagine the learning platform's enrollment workflow. If EnrollmentService creates a PostgreSQL repository, a Stripe client, and an email sender inside its own methods, then the class is doing two jobs at once. It is deciding enrollment rules, and it is deciding runtime infrastructure. That coupling feels harmless at first, but it quickly turns the service into a place where business policy and deployment choices are tangled together.
Once you separate those two responsibilities, the picture sharpens. The service owns the rule, "Can this learner enroll?" The application boundary owns the runtime composition, "In production use PostgreSQL and Stripe; in tests use fakes; in staging use sandbox credentials." That is the real aha: dependency injection is not about fancy construction machinery. It is about putting construction decisions where they belong.
When learners grasp that, a lot of backend architecture starts making more sense. Layers become cleaner. Tests become smaller. Environment-specific behavior stops leaking into core logic. And "runtime composition" stops sounding abstract; it simply means deciding, at startup or at the system edge, what concrete world your code will run with.
Why This Matters
The problem: Backends often begin with classes that directly instantiate repositories, HTTP clients, caches, and message publishers. That makes the first version easy to write, but it hardcodes infrastructure choices into the same code that should be expressing business behavior.
Before:
- Business services open database clients or SDKs directly.
- Tests need real infrastructure or heavy patching just to exercise business rules.
- Changing one integration means editing the consumer instead of changing wiring at the boundary.
After:
- Core logic states its collaborators explicitly.
- Startup code or framework edges assemble concrete implementations once.
- Different environments and tests can swap infrastructure without rewriting the use case.
Real-world impact: This is how you keep a backend adaptable when local development, CI, staging, production, and background workers all need the same behavior but different runtime wiring.
Learning Objectives
By the end of this session, you will be able to:
- Explain what DI really separates - Distinguish dependency declaration from dependency construction.
- Recognize the payoff of substitution - See why explicit collaborators make tests and environment changes easier.
- Identify the composition root - Understand where wiring and lifecycle decisions should live in a backend.
Core Concepts Explained
Concept 1: A Component Should Describe Its Collaborators, Not Build Them
Take one use case: enrolling a learner into a paid course. The business logic needs to check course rules, charge money, record enrollment, and send a confirmation. Those are real collaborators. If the service hides them behind internal new calls, the reader cannot tell from the constructor what the service actually depends on.
That is why explicit dependencies matter. They make the code more honest. The signature becomes a contract that says, "I need a repository, a payment capability, and a notifier." The service can then focus on behavior instead of infrastructure assembly.
class EnrollmentService:
def __init__(self, repo, payments, notifier):
self.repo = repo
self.payments = payments
self.notifier = notifier
def enroll(self, learner_id, course_id):
course = self.repo.get_course(course_id)
if not course["is_open"]:
raise ValueError("course_closed")
self.payments.charge(learner_id, course["price"])
enrollment = self.repo.create_enrollment(learner_id, course_id)
self.notifier.send_enrollment_email(learner_id, course_id)
return enrollment
The code example is small on purpose. The important change is not the constructor syntax itself. It is the separation of responsibilities. The service owns the enrollment workflow. It does not decide whether the repository is PostgreSQL, whether payments go through Stripe, or whether notifications are sent by SMTP or a queue.
The trade-off is a bit more explicit structure up front in exchange for much lower coupling later. That trade is usually worth taking once the code matters beyond a throwaway script.
Concept 2: Substitution Is the Practical Payoff, Not an Academic Bonus
The strongest day-to-day benefit of DI is substitution. Once the consumer depends on a capability instead of constructing one specific implementation, you can swap collaborators without rewriting the business logic.
That matters in more places than tests:
- local development may use an in-memory repository
- staging may use sandbox payment credentials
- production may use the real provider
- a failure drill may inject a fake gateway that times out
The same service can survive all of those contexts because the decision changed from "what implementation do I call?" to "what contract do I require?"
same business rule
|
v
EnrollmentService
/ | \
/ | \
fake sandbox real
repo payments payments
This is also why DI improves tests so dramatically. Good tests should verify business behavior with the minimum runtime needed to make that behavior meaningful. If the service is hardcoded to one infrastructure choice, every test is dragged closer to integration territory than it needs to be.
The trade-off is that substitution works only if the dependency boundary is real. If every consumer still assumes provider-specific behavior, then injection becomes syntax without decoupling.
Concept 3: Runtime Composition Belongs at the Edge of the Application
If business code should not construct its infrastructure, where should that wiring happen? At the composition root: the place where the application starts, the request boundary is configured, or the worker process is assembled. That is where concrete implementations, credentials, pools, and lifecycle rules belong.
Think of the system as two different responsibilities:
core code: "What behavior should happen?"
runtime edge: "With which concrete collaborators will it happen?"
A simple composition root often looks like this:
db = create_connection_pool(DB_URL)
repo = PostgresEnrollmentRepo(db)
payments = StripeGateway(STRIPE_API_KEY)
notifier = EmailNotifier(SMTP_HOST)
enrollment_service = EnrollmentService(repo, payments, notifier)
This is runtime composition in plain form. A framework container can automate part of it, but the architectural idea is already complete without one. In fact, this is the safer starting point because it keeps the object graph visible.
The main smell to avoid here is the service locator pattern in disguise: code that reaches into a global container whenever it needs something. That hides dependencies again and recreates the original problem with fancier tooling.
The trade-off is centralization versus convenience. A composition root creates one more place to maintain, but it is exactly the place where infrastructure decisions should be concentrated.
Troubleshooting
Issue: "We already use a DI container, so our code must be decoupled."
Why it happens / is confusing: Teams often meet DI through framework features first, so they equate the concept with the tool.
Clarification / Fix: Ignore the container for a moment and inspect the consumer. If the class still hides dependencies, reaches into globals, or depends on concrete implementation quirks, the architecture is still tightly coupled.
Issue: Constructor injection feels noisy because every dependency is visible.
Why it happens / is confusing: Hidden construction can feel cleaner at first because the call sites are shorter.
Clarification / Fix: Treat the visible dependency list as useful pressure. If a constructor has too many collaborators, the problem is often not DI itself but that the component owns too many responsibilities.
Advanced Connections
Connection 1: Dependency Injection ↔ Layered Architecture
The parallel: Layers stay clean only when inner code stops deciding outer infrastructure details.
Real-world case: A service layer remains reusable across HTTP handlers, jobs, and CLI tools when its collaborators are injected rather than framework-bound.
Connection 2: Dependency Injection ↔ Testing Strategy
The parallel: The smaller the runtime needed to exercise a rule, the cheaper and more precise the test usually becomes.
Real-world case: Enrollment rules can be tested with fake repositories and fake gateways, while a smaller number of integration tests verify the real adapters and wiring.
Resources
Optional Deepening Resources
- These resources are optional and are not required for the core 30-minute path.
- [ARTICLE] Inversion of Control Containers and the Dependency Injection Pattern
- Link: https://martinfowler.com/articles/injection.html
- Focus: Separate the design idea of DI from the mechanics of containers.
- [BOOK] Clean Architecture
- Link: https://www.oreilly.com/library/view/clean-architecture-a/9780134494272/
- Focus: Connect dependency direction and DI to larger backend architecture boundaries.
- [DOC]
typing.Protocol- Link: https://docs.python.org/3/library/typing.html#typing.Protocol
- Focus: See one concrete way to express small capability-based contracts in Python.
- [DOC] FastAPI Dependencies
- Link: https://fastapi.tiangolo.com/tutorial/dependencies/
- Focus: Compare the core DI idea with a lightweight framework mechanism for wiring request-time collaborators.
Key Insights
- DI separates behavior from construction - The goal is not a container; the goal is to stop business logic from assembling infrastructure.
- Substitution is the practical win - Tests, environments, and failure drills all benefit when collaborators can change without rewriting the use case.
- Composition belongs at the edge - Runtime wiring should be concentrated near startup or application boundaries, not spread through the core.
Knowledge Check (Test Questions)
-
What does dependency injection fundamentally change in a backend design?
- A) It moves dependency construction decisions out of business logic and makes collaborators explicit.
- B) It guarantees every dependency will be abstract and interchangeable.
- C) It removes the need for startup wiring.
-
Why is substitution such an important benefit of DI?
- A) Because the same business logic can run with different collaborators in tests, staging, and production.
- B) Because it lets every class instantiate its own fallback implementation.
- C) Because it makes infrastructure concerns irrelevant.
-
What is the best description of a composition root?
- A) The place where the application's concrete object graph is assembled.
- B) A global registry that any service can query whenever it needs a dependency.
- C) The domain model itself.
Answers
1. A: DI changes who owns construction. The business logic declares what it needs, while the application edge decides what concrete implementations will satisfy those needs.
2. A: Substitution matters because one use case can be exercised under different runtime conditions without changing the core behavior.
3. A: A composition root centralizes wiring and lifecycle decisions. Option B is a service locator smell, which hides dependencies again.