Dependency Injection and Runtime Composition

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:

After:

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:

  1. Explain what DI really separates - Distinguish dependency declaration from dependency construction.
  2. Recognize the payoff of substitution - See why explicit collaborators make tests and environment changes easier.
  3. 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:

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


Key Insights

  1. DI separates behavior from construction - The goal is not a container; the goal is to stop business logic from assembling infrastructure.
  2. Substitution is the practical win - Tests, environments, and failure drills all benefit when collaborators can change without rewriting the use case.
  3. Composition belongs at the edge - Runtime wiring should be concentrated near startup or application boundaries, not spread through the core.

Knowledge Check (Test Questions)

  1. 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.
  2. 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.
  3. 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.



← Back to Learning