Dependency Injection and Runtime Composition

LESSON

Backend and API Architecture

006 30 min intermediate

Dependency Injection and Runtime Composition

The core idea: Dependency injection separates behavior from construction, trading hidden convenience for explicit collaborators that can be composed differently in production, tests, jobs, and local development.

Core Insight

Imagine the EnrollmentService from the previous lesson now needs more than a database. It needs course data, enrollment storage, payment charging, and a way to notify the learner. A quick implementation can simply create those collaborators inside the service: open a database connection, instantiate a payment SDK, build an email client, and keep going.

That version works until the environment changes. Tests should not charge real cards. Local development may not have the email provider configured. A background worker may need the same enrollment rule but a different notification path. A migration script may need to run the rule with a temporary repository. If the service secretly constructs its own world, every environment has to fight the service before it can use the behavior.

Dependency injection is the small design move that fixes that pressure. A component states what it needs, and something outside that component provides the concrete objects. The service still owns the enrollment workflow. Runtime composition owns the decision that production uses PostgreSQL and Stripe, tests use fakes, and staging uses sandbox credentials.

The misconception is that dependency injection means using a framework container. A container can help, but it is not the idea. The idea is visible in a plain constructor: "I need these capabilities to do my job." Once that sentence is honest, wiring can stay simple or become automated later.

The Pressure: Construction Leaks Into Behavior

Here is the problem in its most ordinary form:

class EnrollmentService:
    def enroll(self, learner_id: str, course_id: str):
        db = PostgresClient(os.environ["DB_URL"])
        payments = StripeClient(os.environ["STRIPE_KEY"])
        mailer = SmtpMailer(os.environ["SMTP_HOST"])

        course = db.fetch_course(course_id)
        if not course.is_open:
            raise EnrollmentRejected("course_closed")

        payments.charge(learner_id, course.price)
        enrollment = db.insert_enrollment(learner_id, course_id)
        mailer.send_enrollment_email(learner_id, course_id)
        return enrollment

The method looks self-contained, but it is doing too much. It owns business behavior and runtime construction at the same time. It also makes several hidden decisions:

Those decisions are not enrollment policy. They are deployment and runtime choices. Putting them inside the service makes the service harder to reuse and harder to test.

Dependency injection moves those choices out:

class EnrollmentService:
    def __init__(self, courses, enrollments, payments, notifier):
        self.courses = courses
        self.enrollments = enrollments
        self.payments = payments
        self.notifier = notifier

    def enroll(self, learner_id: str, course_id: str):
        course = self.courses.get(course_id)
        if not course.is_open:
            raise EnrollmentRejected("course_closed")

        self.payments.charge(learner_id, course.price)
        enrollment = self.enrollments.create(learner_id, course_id)
        self.notifier.enrollment_created(learner_id, course_id)
        return enrollment

The service is not magically simpler. It is more honest. Its constructor names the collaborators needed to execute the use case. The code no longer decides which database, payment provider, or notification channel will be used.

Collaborators Are Capabilities

A useful dependency boundary is usually a capability, not a giant generic interface. The service does not need "the whole database." It needs course lookup and enrollment creation. It does not need "the whole payment SDK." It needs the ability to charge a learner for a course.

In a typed Python codebase, the boundary might be expressed with protocols:

from typing import Protocol

class Courses(Protocol):
    def get(self, course_id: str) -> Course:
        ...

class Enrollments(Protocol):
    def create(self, learner_id: str, course_id: str) -> Enrollment:
        ...

class Payments(Protocol):
    def charge(self, learner_id: str, amount_cents: int) -> None:
        ...

The exact language feature is less important than the design pressure. A collaborator boundary should say what the use case needs in terms the use case understands. That makes substitution practical because alternative implementations can satisfy the same capability:

EnrollmentService
    needs Courses, Enrollments, Payments, Notifier

production wiring:
    PostgresCourses, PostgresEnrollments, StripePayments, QueueNotifier

test wiring:
    FakeCourses, InMemoryEnrollments, FakePayments, RecordingNotifier

This is the everyday payoff of dependency injection. The same service can run with real infrastructure, fake infrastructure, sandbox infrastructure, or failure-drill infrastructure without changing the service itself.

The trade-off is explicitness. A class with real dependencies has a real constructor. If the constructor becomes long, that is not a reason to hide the dependencies again. It is a useful signal that the class may be doing too much.

Runtime Composition Happens at the Edge

If the service should not build its collaborators, where should construction happen? At the composition root: the place where the application process assembles concrete objects. In a web app, that may be startup code. In a worker, it may be the worker bootstrap. In a CLI script, it may be the command entry point.

A simple composition root might look like this:

def build_enrollment_service() -> EnrollmentService:
    pool = create_postgres_pool(os.environ["DB_URL"])
    courses = PostgresCourses(pool)
    enrollments = PostgresEnrollments(pool)
    payments = StripePayments(os.environ["STRIPE_KEY"])
    notifier = QueueNotifier(os.environ["EVENTS_TOPIC"])

    return EnrollmentService(
        courses=courses,
        enrollments=enrollments,
        payments=payments,
        notifier=notifier,
    )

This code is not "less architectural" because it is plain. It is architecture in the right place. It centralizes concrete decisions: provider choice, credentials, object construction, and lifecycle.

Lifetimes matter here. A database connection pool is usually process-wide. A database transaction or unit of work may be request-scoped. A lightweight value object can be created per call. A cache client may be shared. If lifetimes are hidden inside business methods, it becomes easy to create a new connection per request accidentally, share request-scoped state globally, or make tests depend on process-wide leftovers.

The composition root makes those decisions reviewable:

process lifetime:    connection pool, HTTP client, message publisher
request lifetime:    unit of work, transaction, request context
call lifetime:       command object, parsed input, result DTO

Framework containers can automate this wiring. FastAPI dependencies, Spring containers, Guice, or NestJS providers can all help with construction and lifetimes. The design question stays the same: can the use case be read without knowing the framework, and are its collaborators explicit?

Worked Example: Testing Without Rewriting the Use Case

Suppose we want to test that a closed course rejects enrollment and never charges payment. With hidden construction, the test has to patch environment variables, intercept SDK creation, or connect to real services. With dependency injection, the test can provide tiny collaborators:

class FakeCourses:
    def get(self, course_id: str):
        return Course(id=course_id, is_open=False, price_cents=5000)

class RecordingPayments:
    def __init__(self):
        self.charges = []

    def charge(self, learner_id: str, amount_cents: int):
        self.charges.append((learner_id, amount_cents))

payments = RecordingPayments()
service = EnrollmentService(
    courses=FakeCourses(),
    enrollments=InMemoryEnrollments(),
    payments=payments,
    notifier=RecordingNotifier(),
)

with raises(EnrollmentRejected):
    service.enroll("learner-1", "course-42")

assert payments.charges == []

The test is not valuable because it is mocked. It is valuable because it exercises the business rule with the minimum runtime needed to make the rule meaningful. Separate integration tests can still verify that PostgresEnrollments talks to PostgreSQL correctly and StripePayments handles provider errors correctly.

This split prevents one common testing trap: using dependency injection to avoid all real integration tests. DI makes substitution possible; it does not prove that the real adapters work.

Failure Modes and Design Limits

The first failure mode is service locator in disguise. Instead of receiving collaborators, the service reaches into a global container:

payments = container.get("payments")

That looks flexible, but it hides the dependency again. The reader cannot see what the service needs from its signature, and tests must configure global state.

The second failure mode is injecting concrete infrastructure but still depending on provider quirks. If the service receives StripeClient and calls Stripe-specific methods everywhere, the dependency is visible but not really substitutable. That may be acceptable for a small app, but it is not the same as a clean capability boundary.

The third failure mode is container-first design. Teams sometimes introduce annotations, modules, scopes, and factories before they have clarified the simple object graph. The result is a backend where wiring is powerful but hard to reason about.

The fourth failure mode is over-abstracting. Not every helper needs an interface. If there is only one implementation, no testing pressure, and no environment variation, direct construction can be fine. The point is to move construction when hidden construction is creating coupling, not to turn every class into a framework exercise.

Connections

The previous lesson on layered architecture explained why services should not know transport or storage details. Dependency injection is the practical wiring technique that lets those services declare their persistence and integration needs without constructing concrete infrastructure.

The next lesson on repositories and units of work depends on this idea. A service can ask for a repository or unit of work only if something at the runtime edge knows how to create the concrete database-backed implementation.

This also connects to API contract testing later in the track. Clear collaborators make it easier to test business behavior separately from HTTP contract translation and persistence adapters.

Resources

Key Takeaways

PREVIOUS Layered Backend Architecture NEXT Repositories, Units of Work, and Persistence Boundaries