Design Patterns, Explained Simply · Foundations: Thinking in Patterns

Dependency Injection Without a Framework

Pass an object its collaborators instead of letting it create them. You get testable code with no container, no annotations and no magic.

The problem

SignupService sends a welcome email and records the signup time. Inside, it does new SmtpMailer() and calls the system clock directly. Its tests send real emails (or need a mail server), and the "signed up today" logic can't be tested for a fixed date. Switching to a different email provider means editing the service.

Real-world analogy

A chef doesn't grow their own vegetables or forge their own knives. The restaurant supplies them. The chef just says "I need a knife and onions". For a cooking class you can supply a plastic knife and fake onions; the chef's technique doesn't change.

The idea

Dependency injection (DI) means an object receives its dependencies from outside instead of creating them:

  • Constructor injection (the default): pass dependencies to the constructor. The object is complete as soon as it exists.
  • Setter or property injection: for optional dependencies that can change later.
  • Method injection: pass a dependency to the single method that needs it.

Somewhere at the edge of the app, usually main() or the framework's bootstrap, one piece of code (the composition root) creates the real objects and wires them together. Frameworks like Spring, Laravel's service container, NestJS and FastAPI's Depends automate this wiring, but the idea works with plain constructors.

The code

The service takes a mailer and a clock. Production wiring uses the real ones; the test wiring uses a fake mailer that records messages and a fixed clock.

Constructor injection with fakes for testingTry it yourself
class ConsoleMailer:  # stands in for a real SMTP mailer
    def send(self, to, text):
        print(f"[mail to {to}] {text}")


class FakeMailer:  # test double: records instead of sending
    def __init__(self):
        self.sent = []

    def send(self, to, text):
        self.sent.append((to, text))


class FixedClock:
    def __init__(self, today):
        self.value = today

    def today(self):
        return self.value


class SignupService:
    def __init__(self, mailer, clock):  # dependencies come in from outside
        self.mailer = mailer
        self.clock = clock

    def sign_up(self, email):
        self.mailer.send(email, "Welcome aboard!")
        return f"{email} joined on {self.clock.today()}"


# Composition root: production wiring
app = SignupService(ConsoleMailer(), FixedClock("2026-10-19"))
print(app.sign_up("[email protected]"))

# Test wiring: no network, fixed date
fake = FakeMailer()
service = SignupService(fake, FixedClock("2030-01-01"))
assert service.sign_up("[email protected]") == "[email protected] joined on 2030-01-01"
print("test passed, emails recorded:", len(fake.sent))

Output

[mail to [email protected]] Welcome aboard!
[email protected] joined on 2026-10-19
test passed, emails recorded: 1

SignupService never changed between production and test. Only the wiring did.

Common mistakes

  • Service locator in disguise. Passing the whole container in and calling container.get("mailer") inside the class hides its dependencies again. Ask for the specific things you need.
  • Ten constructor parameters. That is not a DI problem; it is an SRP warning. The class is doing too much.
  • Injecting values that never vary. You don't need to inject Math or a pure helper function. Inject things that do I/O, hold state, or need faking in tests: databases, clocks, HTTP clients, random numbers.
  • Thinking DI requires a framework. Containers save typing in large apps; constructors are enough for most code.

Key points

  • DI = pass dependencies in instead of creating them inside.
  • Prefer constructor injection; wire real objects in one composition root.
  • It makes code testable (fakes, fixed clocks) and swappable (new providers without edits).
  • Frameworks (Spring, Laravel, NestJS, FastAPI Depends) automate wiring; they don't change the idea.
  • Further reading: Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern.

Practice questions

What are the three common ways to inject a dependency, and which is usually preferred?

Answer

Constructor injection (the default: the object is complete once created and dependencies are explicit), setter/property injection (for optional or changeable dependencies) and method injection (for a dependency only one call needs).

What is a composition root?

Answer

The single place near the entry point of an application (for example main() or the framework bootstrap) where real objects are created and wired together. The rest of the code just receives its dependencies.

Why is a service locator often considered an anti-pattern compared with dependency injection?

Answer

A class that pulls dependencies from a global locator hides what it needs: you can't see its dependencies from its constructor, tests must configure the global locator, and missing registrations fail at runtime. DI makes dependencies explicit.

Which dependencies are worth injecting, and which are not?

Answer

Inject things that do I/O, hold state or need to be faked in tests: databases, HTTP clients, mailers, clocks and random-number generators. Pure helper functions and value objects usually don't need injecting.

Open in the app to mark lessons complete, take notes and track your progress.