Skip to content
Commitwise
Menu
Design Patterns

Dependency injection

Pass a class the things it needs instead of letting it create them, wire everything in one composition root, and test with fakes, with examples in Swift, Kotlin, Java and TypeScript, plus scopes, lifetimes and DI frameworks.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A test for “subscription expired” passes on Monday and fails on Tuesday. Another test makes real network calls and fails on the CI server’s Wi-Fi. Neither test is wrong: the classes they test create their own clock and HTTP client, so the test can’t control them.

Dependency injectionDependency injectionGiving an object the things it needs from outside, instead of letting it create them itself.Open in glossary → (DI) is the simple fix: a class receives what it depends on, instead of creating it. It’s less a pattern than a habit, and it’s what makes code testable, configurable and replaceable.

New to some of these? Open what you need, or skip ahead.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java, TypeScript) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. In TypeScript, plain objects described by an interface or type often play that role.
Basics:What are unit tests and fakes?

A unit test is a small piece of code that runs one part of your app (a function, a class) with chosen inputs and checks the result automatically: “with an expired date, isExpired returns true”.

Tests must be fast and give the same answer every time, so they don’t use the real network, database or clock. Instead they pass in fakes: tiny stand-in objects that behave predictably (a fake API that returns a fixed answer, a clock stuck at one time). Being able to pass fakes in is one of the main reasons for dependency injection.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Basics:What is a singleton?

A singleton is a class with exactly one instance that the whole app shares: a Kotlin object, a Swift static let shared, a Spring bean, or in TypeScript a module that exports one instance (export const cart = new Cart()) (Spring creates one instance of each @Service by default).

Sharing one instance is efficient and convenient, but anything the singleton stores is shared too: by every screen, every user request and every thread. That’s why singletons should hold no per-user or per-request data.

Part Not started

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:

  1. the class declares what it needs, usually as constructor parameters,
  2. whoever creates the class passes in the real things (in the app) or fake ones (in tests),
  3. 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() and new 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

Spot the bugA trial banner test that fails at midnight

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));
  }
}

Practice: testing with fakes

The payoff of DI is tests that are fast, deterministic and readable. Step by step, for SubscriptionService:

  1. Write a fake for each interface: a tiny class that records calls and returns what the test needs.
  2. Pick a fixed time for the clock.
  3. Create the class under test with the fakes.
  4. 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

StrengthCost
Manual DI (constructors + a composition root)no magic, compile-time safe, works everywhereboilerplate grows with the graph
Dagger / Hilt (Android, compile-time)graph validated at build time, scopes tied to Android componentsannotation processing / KSP time, learning curve
Koin (runtime)small, Kotlin DSL, multiplatformmissing bindings fail at runtime (verify modules in a test)
Spring (runtime container)huge ecosystem, profiles, AOP proxiesstartup cost, reflection, hidden wiring if overused
Swift (manual, environment, libraries like swift-dependencies or Factory)protocols + initializers are usually enoughconventions vary by team
TypeScript (manual, NestJS, tsyringe, InversifyJS)manual DI is idiomatic; NestJS adds modules and request scopes on the servercontainers 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() or new 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

  1. What does dependency injection mean?

  2. Why does passing now: () -> Date make isExpired testable?Why does passing a Clock make isExpired testable?Why does passing now: () => Date make isExpired testable?

  3. TrialService takes now: () -> Date but daysLeft computes from Date(). What catches this reliably?TrialService takes a Clock but daysLeft calls LocalDate.now(). What catches this reliably?TrialService takes now: () => Date but daysLeft calls Date.now(). What catches this reliably?

  4. Which dependencies deserve an interface (or protocol)?

A class declares what it needs as constructor parameters and gets real implementations in the app and fakes in tests. Time, network and storage are dependencies too, and an injected dependency only helps if the code actually uses it.

Part Not started

One instance, shared

Injecting the same object into several classes means they share it, with all its state:

Predict the output

One PriceService is passed to both screens. What gets printed?

final class PriceService {
    private(set) var calls = 0
    func price(for id: Int) -> Int { calls += 1; return 10 }
}

struct ProductScreen { let prices: PriceService; func show() { _ = prices.price(for: 1) } }
struct CartScreen { let prices: PriceService; func show() { _ = prices.price(for: 1) } }

let prices = PriceService()
ProductScreen(prices: prices).show()
CartScreen(prices: prices).show()
print(prices.calls)
class PriceService {
    var calls = 0
        private set
    fun price(id: Int): Int { calls++; return 10 }
}

class ProductScreen(private val prices: PriceService) { fun show() = prices.price(1) }
class CartScreen(private val prices: PriceService) { fun show() = prices.price(1) }

val prices = PriceService()
ProductScreen(prices).show()
CartScreen(prices).show()
println(prices.calls)
@Service
public class PriceService {
    private int calls = 0;
    public int price(int id) { calls++; return 10; }
    public int calls() { return calls; }
}

// Spring injects the one PriceService bean into both controllers:
productController.show();      // calls priceService.price(1)
cartController.show();         // calls priceService.price(1)
System.out.println(priceService.calls());
class PriceService {
  calls = 0;
  price(id: number): number { this.calls++; return 10; }
}

class ProductScreen { constructor(private readonly prices: PriceService) {} show() { this.prices.price(1); } }
class CartScreen { constructor(private readonly prices: PriceService) {} show() { this.prices.price(1); } }

const prices = new PriceService();
new ProductScreen(prices).show();
new CartScreen(prices).show();
console.log(prices.calls);

The composition root

Somebody still has to create the real objects. Do it in one place, at the start of the app, called the composition rootComposition rootThe one place at the top of an app where the real dependencies are created and connected.Open in glossary →:

PlatformComposition root
iOS (SwiftUI)the App struct: create services once, pass them down or put them in the environment
Androidthe Application class (manual DI) or Hilt modules (@Module, @Provides, @Binds)
Springthe container: @Service, @Component and @Bean methods, wired by constructor
TypeScript / Nodethe entry file (main.ts, server.ts) that creates services and passes them in, or NestJS modules (providers)

Everything else just declares what it needs. Classes deep in the app never call Retrofit.Builder(), URLSession(configuration:), new RestTemplate() or new PrismaClient() themselves.

Field injection

Frameworks can also inject in a second way: field (or property) injection, where the object is created first, with an empty constructor, and the framework fills in fields marked with @Autowired (Spring), @Inject (Hilt) or @inject on a property (InversifyJS), or a container sets a property, afterwards. That difference is exactly what breaks the next example:

Spot the bugA NullPointerException on startup

Tap the line with the bug.

final class ReportViewModel {
    var repository: ReportRepository!     // set by the container after init

    init() {
        repository.warmUp()
    }
}
@Component
class ReportService {
    @Autowired
    lateinit var repository: ReportRepository

    init {
        repository.warmUp()
    }
}
@Service
public class ReportService {
    @Autowired
    private ReportRepository repository;

    public ReportService() {
        repository.warmUp();
    }
}
@injectable()
class ReportService {
  @inject(TYPES.ReportRepository) private readonly repository!: ReportRepository;

  constructor() {
    this.repository.warmUp();
  }
}

Exerciseeasy

Make a weather screen testable

A weather screen’s logic does three things: loads the forecast with URLSession.shared / Retrofit / RestTemplate / fetch, saves the last result in UserDefaults / SharedPreferences / a file / localStorage, and shows “updated X minutes ago” using the current time. It’s impossible to test without a network and a real clock.

Refactor it for dependency injection, then write one test: “with a saved forecast from 10 minutes ago and no network, it shows the saved forecast and ‘updated 10 minutes ago’”.

›

Three dependencies, three seams: a forecast API interface, a small storage interface (save/load the last forecast), and a clock.

›

The fake API throws a network error; the fake storage starts with a forecast saved at “now minus 10 minutes”; the clock is fixed at “now”.

SolutionTry it yourself first
protocol ForecastAPI { func forecast(city: String) async throws -> Forecast }
protocol ForecastStore { func load() -> SavedForecast?; func save(_ saved: SavedForecast) }

struct SavedForecast: Equatable { let forecast: Forecast; let savedAt: Date }

@MainActor @Observable
final class WeatherModel {
    private(set) var forecast: Forecast?
    private(set) var updatedText = ""

    private let api: any ForecastAPI
    private let store: any ForecastStore
    private let now: () -> Date

    init(api: any ForecastAPI, store: any ForecastStore, now: @escaping () -> Date = Date.init) {
        self.api = api; self.store = store; self.now = now
    }

    func load(city: String) async {
        do {
            let fresh = try await api.forecast(city: city)
            store.save(SavedForecast(forecast: fresh, savedAt: now()))
            show(SavedForecast(forecast: fresh, savedAt: now()))
        } catch {
            if let saved = store.load() { show(saved) }          // offline: last known forecast
        }
    }

    private func show(_ saved: SavedForecast) {
        forecast = saved.forecast
        let minutes = Int(now().timeIntervalSince(saved.savedAt) / 60)
        updatedText = minutes < 1 ? "updated just now" : "updated \(minutes) minutes ago"
    }
}

@Test @MainActor func showsSavedForecastWhenOffline() async {
    let now = Date(timeIntervalSince1970: 1_800_000_000)
    let saved = SavedForecast(forecast: .sunny, savedAt: now.addingTimeInterval(-600))
    let model = WeatherModel(api: OfflineAPI(), store: InMemoryStore(saved: saved), now: { now })

    await model.load(city: "Zagreb")

    #expect(model.forecast == .sunny)
    #expect(model.updatedText == "updated 10 minutes ago")
}
interface ForecastApi { suspend fun forecast(city: String): Forecast }
interface ForecastStore { fun load(): SavedForecast?; fun save(saved: SavedForecast) }

data class SavedForecast(val forecast: Forecast, val savedAt: Instant)

class WeatherViewModel(
    private val api: ForecastApi,
    private val store: ForecastStore,
    private val clock: Clock = Clock.systemUTC(),
) : ViewModel() {
    private val _state = MutableStateFlow(WeatherUiState())
    val state = _state.asStateFlow()

    fun load(city: String) = viewModelScope.launch {
        try {
            val saved = SavedForecast(api.forecast(city), clock.instant())
            store.save(saved)
            show(saved)
        } catch (e: IOException) {
            store.load()?.let(::show)                            // offline: last known forecast
        }
    }

    private fun show(saved: SavedForecast) {
        val minutes = Duration.between(saved.savedAt, clock.instant()).toMinutes()
        _state.value = WeatherUiState(saved.forecast, if (minutes < 1) "updated just now" else "updated $minutes minutes ago")
    }
}

@Test
fun `shows saved forecast when offline`() = runTest {
    Dispatchers.setMain(StandardTestDispatcher(testScheduler))
    val now = Instant.parse("2026-11-01T10:00:00Z")
    val store = InMemoryStore(SavedForecast(Forecast.SUNNY, now.minusSeconds(600)))
    val vm = WeatherViewModel(OfflineApi(), store, Clock.fixed(now, ZoneOffset.UTC))

    vm.load("Zagreb")
    advanceUntilIdle()

    assertEquals(WeatherUiState(Forecast.SUNNY, "updated 10 minutes ago"), vm.state.value)
    Dispatchers.resetMain()
}
public interface ForecastApi { Forecast forecast(String city) throws IOException; }
public interface ForecastStore { Optional<SavedForecast> load(); void save(SavedForecast saved); }
public record SavedForecast(Forecast forecast, Instant savedAt) {}
public record WeatherView(Forecast forecast, String updatedText) {}

@Service
public class WeatherService {
    private final ForecastApi api;
    private final ForecastStore store;
    private final Clock clock;

    public WeatherService(ForecastApi api, ForecastStore store, Clock clock) {
        this.api = api; this.store = store; this.clock = clock;
    }

    public Optional<WeatherView> current(String city) {
        try {
            SavedForecast saved = new SavedForecast(api.forecast(city), clock.instant());
            store.save(saved);
            return Optional.of(view(saved));
        } catch (IOException e) {
            return store.load().map(this::view);                 // offline: last known forecast
        }
    }

    private WeatherView view(SavedForecast saved) {
        long minutes = Duration.between(saved.savedAt(), clock.instant()).toMinutes();
        return new WeatherView(saved.forecast(), minutes < 1 ? "updated just now" : "updated " + minutes + " minutes ago");
    }
}

@Test
void showsSavedForecastWhenOffline() {
    Instant now = Instant.parse("2026-11-01T10:00:00Z");
    var store = new InMemoryStore(new SavedForecast(Forecast.SUNNY, now.minusSeconds(600)));
    var service = new WeatherService(new OfflineApi(), store, Clock.fixed(now, ZoneOffset.UTC));

    assertEquals(Optional.of(new WeatherView(Forecast.SUNNY, "updated 10 minutes ago")), service.current("Zagreb"));
}
interface ForecastApi { forecast(city: string): Promise<Forecast>; }
interface ForecastStore { load(): SavedForecast | undefined; save(saved: SavedForecast): void; }
interface SavedForecast { forecast: Forecast; savedAt: Date; }
interface WeatherView { forecast: Forecast; updatedText: string; }

class WeatherModel {
  constructor(
    private readonly api: ForecastApi,
    private readonly store: ForecastStore,
    private readonly now: () => Date = () => new Date(),
  ) {}

  async load(city: string): Promise<WeatherView | undefined> {
    let saved: SavedForecast | undefined;
    try {
      saved = { forecast: await this.api.forecast(city), savedAt: this.now() };
      this.store.save(saved);
    } catch (error) {
      if (!(error instanceof NetworkError)) throw error;
      saved = this.store.load();                              // offline: last known forecast
    }
    return saved && this.view(saved);
  }

  private view(saved: SavedForecast): WeatherView {
    const minutes = Math.floor((this.now().getTime() - saved.savedAt.getTime()) / 60_000);
    return { forecast: saved.forecast, updatedText: minutes < 1 ? "updated just now" : `updated ${minutes} minutes ago` };
  }
}

test("shows saved forecast when offline", async () => {
  const now = new Date("2026-11-01T10:00:00Z");
  const store = new InMemoryStore({ forecast: "sunny", savedAt: new Date(now.getTime() - 600_000) });
  const model = new WeatherModel(new OfflineApi(), store, () => now);

  expect(await model.load("Zagreb")).toEqual({ forecast: "sunny", updatedText: "updated 10 minutes ago" });
});

Every source of non-determinism (network, storage, time) is now a constructor parameter. The test reads like the requirement: given these conditions, expect this text. The real app passes URLSession/Retrofit/RestTemplate/fetch-based implementations, UserDefaults/DataStore/file/localStorage storage, and the system clock, from the composition root.

Modules and boundaries

Exercisemedium

DI for a modular app

An app is split into modules: :feature:catalog, :feature:cart, :feature:checkout, :core:network, :core:database, :core:analytics. Today every feature depends on every core module’s concrete classes, the cart creates its own Retrofit, tests start a real database, and changing the analytics vendor touched 40 files.

Design the dependency structure: module boundaries, where interfaces live, where wiring happens, scopes, and how tests replace dependencies. Use Hilt (Android), or describe the equivalent for iOS.

›

Split each core capability into an api module (interfaces and models) and an impl module (the concrete classes). Features depend only on api modules.

›

Only the app module (the composition root) depends on impl modules and binds interfaces to implementations. Tests replace bindings at that level.

SolutionTry it yourself first
// iOS equivalent: protocols in small "Interface" packages, implementations in "Live" packages,
// and the App target as the only composition root.

// AnalyticsInterface package
public protocol Analytics: Sendable { func track(_ event: AnalyticsEvent) }

// AnalyticsLive package
public struct VendorAnalytics: Analytics { /* wraps the vendor SDK */ }

// CartFeature package: depends on AnalyticsInterface and NetworkInterface only
@MainActor @Observable
public final class CartModel {
    private let repository: any CartRepository
    private let analytics: any Analytics
    public init(repository: any CartRepository, analytics: any Analytics) { … }
}

// App target, the composition root
@main struct ShopApp: App {
    private let dependencies = AppDependencies.live()     // builds every Live implementation once

    var body: some Scene {
        WindowGroup { RootView().environment(dependencies) }
    }
}
// Tests and previews build AppDependencies with in-memory or recording implementations.
// :core:analytics:api, what features see
interface Analytics {
    fun track(event: AnalyticsEvent)
}

// :core:analytics:impl, the only module that knows the vendor
internal class VendorAnalytics @Inject constructor(private val sdk: VendorSdk) : Analytics {
    override fun track(event: AnalyticsEvent) = sdk.log(event.name, event.params)
}

@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
    @Binds @Singleton
    abstract fun bindAnalytics(impl: VendorAnalytics): Analytics
}

// :feature:cart, depends on :core:analytics:api and :core:network:api only
@HiltViewModel
class CartViewModel @Inject constructor(
    private val cart: CartRepository,          // interface from :feature:cart's own data layer
    private val analytics: Analytics,          // interface from :core:analytics:api
) : ViewModel()

// Tests: swap the binding for the whole test, no real vendor SDK, no network
@HiltAndroidTest
@UninstallModules(AnalyticsModule::class)
class CartFlowTest {
    @BindValue @JvmField val analytics: Analytics = RecordingAnalytics()
    // …
}

The structure, and why:

  • api / impl split per capability: features compile against small interface modules, so changing the analytics vendor changes one impl module and one binding, not 40 files. Build times improve too: editing an impl doesn’t recompile features.
  • The app module is the only composition root: it depends on every impl module and binds them (Hilt modules installed in the app’s graph; the App struct on iOS). Feature modules never construct core implementations.
  • Scopes follow lifetimes: network client, database and analytics are singletons; repositories are singletons when they’re stateless or own an app-wide cache; anything per-screen lives in the ViewModel scope. Nothing long-lived takes an Activity or a view.
  • Tests replace bindings, not code: Hilt’s @UninstallModules + @BindValue, a test AppDependencies on iOS, or plain constructors in unit tests. Fakes (in-memory database, recording analytics, fake network) live in shared testing modules next to each api module.
  • Guardrails: a dependency rule check (Gradle module graph assertions, or a lint step) that fails the build when a feature depends on an impl module, so the structure doesn’t erode.
SwiftKotlinJavaTypeScript
Composition rootthe App struct, AppDependenciesApplication / Hilt modules (@Binds, @Provides)Spring container (@Bean, constructor wiring)the entry file (main.ts), NestJS modules
Test swaptest AppDependencies, initializer arguments@UninstallModules + @BindValue@MockitoBean, test configurationconstructor arguments, Test.createTestingModule().overrideProvider()
  • No field injection: the object exists before its dependencies, hides them, and breaks in the constructor.
  • In modular apps, features depend on api modules; only the app binds implementations, and a module-graph check keeps it that way.

Check this part

  1. One PriceService is passed to two screens, and each calls price once. What is calls afterwards?

  2. Where should the real network client, database and clock be created?

  3. A view model has var repository: ReportRepository! set by a container after init, and init calls repository.warmUp(). What happens?A Spring @Component has @Autowired lateinit var repository and calls repository.warmUp() in init. What happens?A Spring @Service has an @Autowired field and calls repository.warmUp() in its constructor. What happens?An InversifyJS class has @inject(...) private repository!: ReportRepository and calls this.repository.warmUp() in its constructor. What happens?

  4. In a multi-module app, which module may depend on the implementation (impl) modules?

Create the real objects once, in one composition root; everything else declares what it needs. Shared instances share state, and constructor injection beats field injection because the dependency exists before the constructor runs.

Part Not started

Lifetimes: who lives longer

Every injected object has a lifetime: some live as long as the app (a network client), some as long as one screen, some only for one request on a server. Problems start when a long-lived object keeps a reference to a short-lived one, because then the short-lived one can’t go away.

Lifetimes are where DI graphs go wrong in production. The rule fits in one line (an object may hold only what lives at least as long as it does), and both bugs below break it.

A screen is the classic example: it lives only while it’s shown, but an app-wide singleton lives until the app quits. If the singleton keeps a reference to the screen, the screen can never be freed. The rule: an object may hold references only to objects that live at least as long as it does. Pass short-lived things as parameters, and store only data (like a screen’s name) in long-lived objects.

A singleton that remembers a screen

Spot the bugMemory grows with every screen opened

Tap the line with the bug.

final class AnalyticsTracker {
    static let shared = AnalyticsTracker()
    private var currentScreen: UIViewController?

    func screenShown(_ screen: UIViewController) { currentScreen = screen }
    func track(_ event: String) { send(event, screen: currentScreen?.title) }
}
@Singleton
class AnalyticsTracker @Inject constructor() {
    private var currentScreen: Activity? = null

    fun screenShown(activity: Activity) { currentScreen = activity }
    fun track(event: String) = send(event, screen = currentScreen?.localClassName)
}
@Singleton
public class AnalyticsTracker {
    private Activity currentScreen;

    @Inject public AnalyticsTracker() {}
    public void screenShown(Activity activity) { currentScreen = activity; }
    public void track(String event) { send(event, currentScreen == null ? null : currentScreen.getLocalClassName()); }
}
class AnalyticsTracker {
  static readonly shared = new AnalyticsTracker();
  private currentScreen: HTMLElement | null = null;

  screenShown(screen: HTMLElement) { this.currentScreen = screen; }
  track(event: string) { send(event, this.currentScreen?.dataset.screen); }
}

Service locators, scopes and captive dependencies

  • Service locator (Locator.get<Analytics>(), @Environment read deep inside non-view code, Koin’s get() inside business classes, a container.get() in TypeScript services) is a global registry: dependencies are hidden again, and tests must configure global state. Use locators only at the edges where constructors aren’t yours (Android framework classes before Hilt, SwiftUI views reading the environment).
  • Scopes: singleton (app), activity/screen, ViewModel, request (server), and unscoped (new each time). Every object’s lifetime should be at most the lifetime of what it’s injected into, never longer and never shorter.
  • Captive dependencies: a long-lived object holding a short-lived one makes the short-lived one long-lived. A singleton holding a request-scoped object serves one request’s data to everyone; one holding an Activity leaks the screen (both puzzles).
  • Prefer stateless singletons: services that keep state per user or per request belong in that scope, or the state belongs in a parameter.

A short-lived object inside a singleton

Predict the output

User A adds a mug to their cart. Then user B, in a separate request, calls count(). What does B get?

@Component
@Scope("prototype")
class Cart {
    val items = mutableListOf<String>()
}

@Service
class CheckoutService(private val cart: Cart) {
    fun add(item: String) { cart.items += item }
    fun count() = cart.items.size
}

// request from user A:  checkoutService.add("mug")
// request from user B:  checkoutService.count()
@Component
@Scope("prototype")
public class Cart {
    final List<String> items = new ArrayList<>();
}

@Service
public class CheckoutService {
    private final Cart cart;
    public CheckoutService(Cart cart) { this.cart = cart; }
    public void add(String item) { cart.items.add(item); }
    public int count() { return cart.items.size(); }
}

// request from user A:  checkoutService.add("mug");
// request from user B:  checkoutService.count();
@Injectable({ scope: Scope.TRANSIENT })
class Cart {
  readonly items: string[] = [];
}

@Injectable()                                   // default scope: one instance for the app
class CheckoutService {
  constructor(private readonly cart: Cart) {}
  add(item: string) { this.cart.items.push(item); }
  count() { return this.cart.items.length; }
}

// request from user A:  checkoutService.add("mug")
// request from user B:  checkoutService.count()

Deep dives

Deep diveDI, IoC and the dependency inversion principleInterview favorite

Three terms that get mixed up in interviews:

  • Dependency inversion principle (the D in SOLID): high-level code should depend on abstractions, not on concrete low-level classes. It’s a design rule.
  • Inversion of control: the framework calls your code instead of your code calling the framework (lifecycle callbacks, controllers, @Scheduled). It’s a broader idea.
  • Dependency injection: a technique where objects receive their dependencies instead of creating them. It’s one way to apply the principle, and DI containers are one kind of inversion of control.

You can do DI without a container (plain constructors) and without interfaces (concrete classes), which is often the right amount.

  • An object references only things that live at least as long as it does. Long-lived holding short-lived is a captive dependency: a leaked screen, or one request’s data served to everyone.
  • Request- or user-specific data goes into parameters, per-request lookups, or a provider resolved per use, never into a singleton’s field.

Kotlin, Java

A prototype bean injected into a singleton is created once; use ObjectProvider<T> or a scoped proxy. Hilt rejects an @ActivityContext in a @Singleton, but nothing stops storing a method parameter.

TypeScript

NestJS Scope.TRANSIENT providers injected into a default (singleton) provider are created once for it; a module-level object in a Node server is shared by every request.

Swift

A static let shared holding a view controller keeps it (and its views) alive; the Memory Graph debugger shows the path. Store names or IDs, or a weak reference when you truly need the object.

Check this part

  1. An app-wide singleton stores the last UIViewController it saw. What happens to that screen after the user leaves it?An app-wide singleton stores the last Activity it saw. What happens to that screen after the user leaves it?An app-wide singleton stores the last screen's DOM element. What happens to it after the user navigates away?

  2. Which rule prevents this kind of bug?

  3. A NestJS Scope.TRANSIENT Cart is injected into a default-scoped CheckoutService. User A adds a mug; user B calls count(). What does B get?A @Scope("prototype") Cart is constructor-injected into a singleton CheckoutService. User A adds a mug; user B calls count(). What does B get?

  4. Business code reads its analytics through Locator.shared.resolve(Analytics.self). What is the cost?Business classes call Koin's get<Analytics>() instead of taking Analytics in the constructor. What is the cost?Business classes call context.getBean(Analytics.class) instead of taking Analytics in the constructor. What is the cost?Business classes call container.get(Analytics) instead of taking Analytics in the constructor. What is the cost?

An object may hold only things that live at least as long as it does. A singleton holding a screen leaks it; a singleton holding a per-request object serves one user's data to everyone.

Key takeaways

  • A class should receive what it needs (usually through its constructor) instead of creating it.
  • Inject network, storage and time behind small interfaces, and use them everywhere, including “now”.
  • Create the real objects once, in a composition root; tests pass fakes instead.
  • Shared instances share state: keep injected services stateless.
  • Match lifetimes: nothing may hold a dependency that should die sooner (captive dependencies, leaked Activities).
  • Prefer constructor injection and a single composition root over service locators and field injection.
  • Choose a framework for the graph you have; manual DI is enough more often than expected.
  • In modular apps, features depend on api modules; only the app binds implementations, and tests swap bindings there.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.