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.
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
interface Mailer { send(to: string, text: string): void; }
interface Clock { today(): string; }
class ConsoleMailer implements Mailer { // stands in for a real SMTP mailer
send(to: string, text: string) { console.log(`[mail to ${to}] ${text}`); }
}
class FakeMailer implements Mailer { // test double: records instead of sending
sent: [string, string][] = [];
send(to: string, text: string) { this.sent.push([to, text]); }
}
class FixedClock implements Clock {
constructor(private value: string) {}
today() { return this.value; }
}
class SignupService {
// dependencies come in from outside
constructor(private mailer: Mailer, private clock: Clock) {}
signUp(email: string): string {
this.mailer.send(email, "Welcome aboard!");
return `${email} joined on ${this.clock.today()}`;
}
}
// Composition root: production wiring
const app = new SignupService(new ConsoleMailer(), new FixedClock("2026-10-19"));
console.log(app.signUp("[email protected]"));
// Test wiring: no network, fixed date
const fake = new FakeMailer();
const service = new SignupService(fake, new FixedClock("2030-01-01"));
if (service.signUp("[email protected]") !== "[email protected] joined on 2030-01-01") throw new Error("test failed");
console.log("test passed, emails recorded:", fake.sent.length);
Output
[mail to [email protected]] Welcome aboard! [email protected] joined on 2026-10-19 test passed, emails recorded: 1
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
// Composition root: production wiring
var app = new SignupService(new ConsoleMailer(), new FixedClock("2026-10-19"));
System.out.println(app.signUp("[email protected]"));
// Test wiring: no network, fixed date
var fake = new FakeMailer();
var service = new SignupService(fake, new FixedClock("2030-01-01"));
if (!service.signUp("[email protected]").equals("[email protected] joined on 2030-01-01")) {
throw new AssertionError("test failed");
}
System.out.println("test passed, emails recorded: " + fake.sent.size());
}
}
interface Mailer { void send(String to, String text); }
interface Clock { String today(); }
class ConsoleMailer implements Mailer { // stands in for a real SMTP mailer
public void send(String to, String text) { System.out.println("[mail to " + to + "] " + text); }
}
class FakeMailer implements Mailer { // test double: records instead of sending
final List<String> sent = new ArrayList<>();
public void send(String to, String text) { sent.add(to + ": " + text); }
}
record FixedClock(String value) implements Clock {
public String today() { return value; }
}
class SignupService {
private final Mailer mailer;
private final Clock clock;
SignupService(Mailer mailer, Clock clock) { // dependencies come in from outside
this.mailer = mailer;
this.clock = clock;
}
String signUp(String email) {
mailer.send(email, "Welcome aboard!");
return email + " joined on " + clock.today();
}
}
Output
[mail to [email protected]] Welcome aboard! [email protected] joined on 2026-10-19 test passed, emails recorded: 1
Run this on your computer — see “Get Started” for setup.
<?php
interface Mailer { public function send(string $to, string $text): void; }
interface Clock { public function today(): string; }
class ConsoleMailer implements Mailer { // stands in for a real SMTP mailer
public function send(string $to, string $text): void { echo "[mail to $to] $text\n"; }
}
class FakeMailer implements Mailer { // test double: records instead of sending
public array $sent = [];
public function send(string $to, string $text): void { $this->sent[] = [$to, $text]; }
}
class FixedClock implements Clock {
public function __construct(private string $value) {}
public function today(): string { return $this->value; }
}
class SignupService {
// dependencies come in from outside
public function __construct(private Mailer $mailer, private Clock $clock) {}
public function signUp(string $email): string {
$this->mailer->send($email, "Welcome aboard!");
return "$email joined on {$this->clock->today()}";
}
}
// Composition root: production wiring
$app = new SignupService(new ConsoleMailer(), new FixedClock("2026-10-19"));
echo $app->signUp("[email protected]"), "\n";
// Test wiring: no network, fixed date
$fake = new FakeMailer();
$service = new SignupService($fake, new FixedClock("2030-01-01"));
if ($service->signUp("[email protected]") !== "[email protected] joined on 2030-01-01") throw new Exception("test failed");
echo "test passed, emails recorded: ", count($fake->sent), "\n";
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
Mathor 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.