From hidden dependencies to injected ones
Think of a coffee machine. A machine with a built-in water tank, made of welded metal, only ever uses that tank. A machine with a slot for the tank works with any tank that fits: the normal one, a bigger one for the office, a transparent one for testing whether the pump works.
A class that writes URLSession.shared, Retrofit.Builder()…build(), Instant.now() or new Date() inside itself has a welded tank. With dependency injection, it gets a slot instead:
- the class declares what it needs, usually as constructor parameters,
- whoever creates the class passes in the real things (in the app) or fake ones (in tests),
- the class doesn’t know or care which it got, as long as they fit the slot (an interface or protocol).
That’s all DI is. Frameworks like Hilt, Spring, Koin or NestJS just automate step 2.
Dependency injection inverts who constructs collaborators: objects declare dependencies (preferably via constructors), and a composition root at the application boundary builds the object graph. The benefits are explicit dependencies, substitutability (tests, configurations, platforms) and centralized lifetimes.
The senior concerns are about the graph rather than single classes: scopes and lifetimes (and captive dependencies, where a long-lived object holds a short-lived one), the trade-offs of compile-time (Dagger/Hilt), runtime (Spring, Koin, NestJS) and manual DI, service locators disguised as DI, module boundaries in multi-module apps, and over-abstraction. The Detailed version covers them.
Before: The class builds its own world
final class SubscriptionService {
private let api = APIClient(baseURL: URL(string: "https://api.example.com")!)
func isExpired(_ subscription: Subscription) -> Bool {
subscription.endsAt < Date() // the real clock, always
}
func renew(_ id: String) async throws {
try await api.post("subscriptions/\(id)/renew") // the real network, always
}
}class SubscriptionService {
private val api = Retrofit.Builder().baseUrl("https://api.example.com/").build()
.create(SubscriptionApi::class.java)
fun isExpired(subscription: Subscription): Boolean =
subscription.endsAt.isBefore(Instant.now()) // the real clock, always
suspend fun renew(id: String) = api.renew(id) // the real network, always
}public class SubscriptionService {
private final SubscriptionApi api = new RestSubscriptionApi("https://api.example.com");
public boolean isExpired(Subscription subscription) {
return subscription.endsAt().isBefore(Instant.now()); // the real clock, always
}
public void renew(String id) {
api.renew(id); // the real network, always
}
}class SubscriptionService {
private readonly api = new ApiClient("https://api.example.com");
isExpired(subscription: Subscription): boolean {
return subscription.endsAt < new Date(); // the real clock, always
}
async renew(id: string): Promise<void> {
await this.api.post("subscriptions/" + id + "/renew"); // the real network, always
}
}After: Dependencies come in through the constructor
protocol SubscriptionAPI {
func renew(id: String) async throws
}
final class SubscriptionService {
private let api: any SubscriptionAPI
private let now: () -> Date
init(api: any SubscriptionAPI, now: @escaping () -> Date = Date.init) {
self.api = api
self.now = now
}
func isExpired(_ subscription: Subscription) -> Bool {
subscription.endsAt < now()
}
func renew(_ id: String) async throws {
try await api.renew(id: id)
}
}
// In the app: SubscriptionService(api: LiveSubscriptionAPI(client: apiClient))
// In a test: SubscriptionService(api: FakeSubscriptionAPI(), now: { Date(timeIntervalSince1970: 1_800_000_000) })interface SubscriptionApi {
suspend fun renew(id: String)
}
class SubscriptionService(
private val api: SubscriptionApi,
private val clock: Clock = Clock.systemUTC(), // java.time.Clock
) {
fun isExpired(subscription: Subscription): Boolean =
subscription.endsAt.isBefore(clock.instant())
suspend fun renew(id: String) = api.renew(id)
}
// In the app: SubscriptionService(retrofit.create(SubscriptionApi::class.java))
// In a test: SubscriptionService(FakeSubscriptionApi(), Clock.fixed(Instant.parse("2026-11-01T10:00:00Z"), ZoneOffset.UTC))public interface SubscriptionApi {
void renew(String id);
}
public class SubscriptionService {
private final SubscriptionApi api;
private final Clock clock;
public SubscriptionService(SubscriptionApi api, Clock clock) {
this.api = api;
this.clock = clock;
}
public boolean isExpired(Subscription subscription) {
return subscription.endsAt().isBefore(clock.instant());
}
public void renew(String id) {
api.renew(id);
}
}
// In Spring, the container calls the constructor with the beans it has.
// In a test: new SubscriptionService(new FakeSubscriptionApi(), Clock.fixed(Instant.parse("2026-11-01T10:00:00Z"), ZoneOffset.UTC))interface SubscriptionApi {
renew(id: string): Promise<void>;
}
class SubscriptionService {
constructor(
private readonly api: SubscriptionApi,
private readonly now: () => Date = () => new Date(),
) {}
isExpired(subscription: Subscription): boolean {
return subscription.endsAt < this.now();
}
renew(id: string): Promise<void> {
return this.api.renew(id);
}
}
// In the app: new SubscriptionService(new HttpSubscriptionApi(http))
// In a test: new SubscriptionService(new FakeSubscriptionApi(), () => new Date("2026-11-01T10:00:00Z"))What changed:
- The dependencies are visible: read the constructor and you know everything the class needs.
- Time is a dependency too.
Date(),Instant.now()andnew Date()are hidden inputs; passing a clock makes “is it expired?” testable on any day of the week. - The network sits behind an interface, so tests use a fake that returns instantly and never touches Wi-Fi.
- Defaults keep the app code short: the Swift, Kotlin and TypeScript versions use the real clock unless told otherwise.
Injected, then ignored
Tap the line with the bug.
final class TrialService {
private let now: () -> Date
init(now: @escaping () -> Date = Date.init) { self.now = now }
func daysLeft(_ trial: Trial) -> Int {
let days = Calendar.current.dateComponents([.day], from: Date(), to: trial.endsAt).day ?? 0
return max(0, days)
}
}class TrialService(private val clock: Clock = Clock.systemUTC()) {
fun daysLeft(trial: Trial): Long {
val days = ChronoUnit.DAYS.between(LocalDate.now(), trial.endsOn)
return maxOf(0, days)
}
}public class TrialService {
private final Clock clock;
public TrialService(Clock clock) { this.clock = clock; }
public long daysLeft(Trial trial) {
long days = ChronoUnit.DAYS.between(LocalDate.now(), trial.endsOn());
return Math.max(0, days);
}
}class TrialService {
constructor(private readonly now: () => Date = () => new Date()) {}
daysLeft(trial: Trial): number {
const ms = trial.endsAt.getTime() - Date.now();
return Math.max(0, Math.floor(ms / 86_400_000));
}
}Hint: The test passes a fixed clock. Which clock does daysLeft actually read?
The clock is injected and then not used: Date(), LocalDate.now() and Date.now() read the real system clock. The test passes a fixed date, the code ignores it, and the result depends on when the test runs: it fails around midnight, at month ends, and in CI servers in other time zones. Code review misses it because the class “obviously” has a clock.
Use the injected dependency everywhere, including for “today”:
let days = Calendar.current.dateComponents([.day], from: now(), to: trial.endsAt).day ?? 0val days = ChronoUnit.DAYS.between(LocalDate.now(clock), trial.endsOn)long days = ChronoUnit.DAYS.between(LocalDate.now(clock), trial.endsOn());const ms = trial.endsAt.getTime() - this.now().getTime();A lint rule that flags Date(), Instant.now(), LocalDate.now(), new Date() and Date.now() without a clock argument outside the composition root catches this whole class of bug.
Practice: testing with fakes
The payoff of DI is tests that are fast, deterministic and readable. Step by step, for SubscriptionService:
- Write a fake for each interface: a tiny class that records calls and returns what the test needs.
- Pick a fixed time for the clock.
- Create the class under test with the fakes.
- Assert on results and on what the fake recorded.
import Testing
final class FakeSubscriptionAPI: SubscriptionAPI {
private(set) var renewed: [String] = []
func renew(id: String) async throws { renewed.append(id) }
}
@Test func expiredWhenEndDateIsInThePast() async throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let api = FakeSubscriptionAPI()
let service = SubscriptionService(api: api, now: { now })
#expect(service.isExpired(Subscription(endsAt: now.addingTimeInterval(-1))))
#expect(!service.isExpired(Subscription(endsAt: now.addingTimeInterval(3600))))
try await service.renew("sub-1")
#expect(api.renewed == ["sub-1"])
}class FakeSubscriptionApi : SubscriptionApi {
val renewed = mutableListOf<String>()
override suspend fun renew(id: String) { renewed += id }
}
class SubscriptionServiceTest {
private val now = Instant.parse("2026-11-01T10:00:00Z")
private val api = FakeSubscriptionApi()
private val service = SubscriptionService(api, Clock.fixed(now, ZoneOffset.UTC))
@Test
fun `expired when end date is in the past`() = runTest {
assertTrue(service.isExpired(Subscription(endsAt = now.minusSeconds(1))))
assertFalse(service.isExpired(Subscription(endsAt = now.plusSeconds(3600))))
service.renew("sub-1")
assertEquals(listOf("sub-1"), api.renewed)
}
}class FakeSubscriptionApi implements SubscriptionApi {
final List<String> renewed = new ArrayList<>();
public void renew(String id) { renewed.add(id); }
}
class SubscriptionServiceTest {
private final Instant now = Instant.parse("2026-11-01T10:00:00Z");
private final FakeSubscriptionApi api = new FakeSubscriptionApi();
private final SubscriptionService service = new SubscriptionService(api, Clock.fixed(now, ZoneOffset.UTC));
@Test
void expiredWhenEndDateIsInThePast() {
assertTrue(service.isExpired(new Subscription(now.minusSeconds(1))));
assertFalse(service.isExpired(new Subscription(now.plusSeconds(3600))));
service.renew("sub-1");
assertEquals(List.of("sub-1"), api.renewed);
}
}import { expect, test } from "vitest";
class FakeSubscriptionApi implements SubscriptionApi {
readonly renewed: string[] = [];
async renew(id: string) { this.renewed.push(id); }
}
test("expired when end date is in the past", async () => {
const now = new Date("2026-11-01T10:00:00Z");
const api = new FakeSubscriptionApi();
const service = new SubscriptionService(api, () => now);
expect(service.isExpired({ endsAt: new Date(now.getTime() - 1_000) })).toBe(true);
expect(service.isExpired({ endsAt: new Date(now.getTime() + 3_600_000) })).toBe(false);
await service.renew("sub-1");
expect(api.renewed).toEqual(["sub-1"]);
});No mocking framework, no network, no sleeping, no dependence on today’s date. Hand-written fakes like these are often clearer than mocks, and they’re reusable across tests.
Frameworks and their trade-offs
| Strength | Cost | |
|---|---|---|
| Manual DI (constructors + a composition root) | no magic, compile-time safe, works everywhere | boilerplate grows with the graph |
| Dagger / Hilt (Android, compile-time) | graph validated at build time, scopes tied to Android components | annotation processing / KSP time, learning curve |
| Koin (runtime) | small, Kotlin DSL, multiplatform | missing bindings fail at runtime (verify modules in a test) |
| Spring (runtime container) | huge ecosystem, profiles, AOP proxies | startup cost, reflection, hidden wiring if overused |
| Swift (manual, environment, libraries like swift-dependencies or Factory) | protocols + initializers are usually enough | conventions vary by team |
| TypeScript (manual, NestJS, tsyringe, InversifyJS) | manual DI is idiomatic; NestJS adds modules and request scopes on the server | containers rely on decorator metadata; interfaces vanish at runtime, so they need tokens |
Inject interfaces at boundaries (network, storage, time, randomness, platform services) and concrete classes elsewhere: an interface per class adds indirection without adding substitutability anyone uses.
- Constructor injection, with production defaults where the language has them; inject boundaries (network, storage, time, randomness, platform services), not an interface per class.
- Injected but unused is the classic bug:
Instant.now()ornew Date()next to an injected clock. A lint rule against clock reads outside the composition root catches it.
Kotlin, Java
Hilt/Dagger validate the graph at build time; Koin and Spring fail at runtime on a missing binding (Koin: verify modules in a test; Spring: a context-loading test).
Swift
Initializers with protocol parameters are usually enough; swift-dependencies or Factory add environment-style overrides for tests and previews.
TypeScript
Manual DI with constructor parameters is idiomatic. NestJS, tsyringe and InversifyJS resolve by decorator metadata at runtime, so interfaces (which vanish at runtime) need injection tokens.
final class OrderService {
init(api: any OrdersAPI, store: any OrderStore, now: @escaping () -> Date = Date.init,
newID: @escaping () -> UUID = UUID.init) { … }
}class OrderService(
private val api: OrdersApi,
private val store: OrderStore,
private val clock: Clock = Clock.systemUTC(),
private val ids: () -> UUID = UUID::randomUUID,
)@Service
public class OrderService {
public OrderService(OrdersApi api, OrderStore store, Clock clock, Supplier<UUID> ids) { … }
}
@Bean Clock clock() { return Clock.systemUTC(); } // tests: Clock.fixed(...)class OrderService {
constructor(
private readonly api: OrdersApi,
private readonly store: OrderStore,
private readonly now: () => Date = () => new Date(),
private readonly newId: () => string = () => crypto.randomUUID(),
) {}
}Check this part
What does dependency injection mean?
Why does passing
now: () -> DatemakeisExpiredtestable?Why does passing aClockmakeisExpiredtestable?Why does passingnow: () => DatemakeisExpiredtestable?TrialServicetakesnow: () -> DatebutdaysLeftcomputes fromDate(). What catches this reliably?TrialServicetakes aClockbutdaysLeftcallsLocalDate.now(). What catches this reliably?TrialServicetakesnow: () => DatebutdaysLeftcallsDate.now(). What catches this reliably?Which dependencies deserve an interface (or protocol)?