Skip to content
academia.sh

Lesson 04 / 19

Dependency Injection

An object receiving the dependency it needs from outside instead of building it itself, and how what a fake dependency imitates determines the separation of a check.

Published Updated

In this lesson

  • Distinguishes the effect on checking of building a dependency inside from passing it in from outside
  • Measures why a check tied to the real network client fails on both versions
  • Builds the check that catches the fault with a stub that returns an empty reply
  • Shows that which path a fake dependency imitates makes the check separating or blind
Contents

In the third lesson we gathered the state in one place and measured the reducer without a screen. The reducer was easy to measure because it takes nothing from the outside. Most objects are not like that: a product list view model gets its products from a network client, an order object reads its record from a store. If these dependencies are built inside the object, checking the object means running the dependency too.

Dependency injection reverses this link: the object does not build the dependency it needs, it receives it from outside. That way, in a check, a fake dependency that resembles the real one can be passed in its place. This lesson’s fault is in the product list: when the client gets an empty reply, it clears the list instead of keeping the previous one. The core comes in verbatim, as in the earlier lessons.

// Separation.swift — classifies whether a check tells the correct version from the faulty one
struct Contrast<Output: CustomStringConvertible> {
    let label: String
    let correct: Output
    let faulty: Output
    let passesCorrect: Bool
    let passesFaulty: Bool

    var separation: String {
        switch (passesCorrect, passesFaulty) {
        case (true, false): return "separating"
        case (true, true): return "blind"
        case (false, false): return "noisy"
        case (false, true): return "inverted"
        }
    }

    func describe() -> String {
        let onCorrect = passesCorrect ? "pass" : "fail"
        let onFaulty = passesFaulty ? "pass" : "fail"
        print("\(label) | correct=\(correct)/\(onCorrect) faulty=\(faulty)/\(onFaulty) -> \(separation)")
        return separation
    }
}

func contrast<Output>(_ label: String, correct: Output, faulty: Output, check: (Output) -> Bool) -> Contrast<Output> {
    Contrast(label: label, correct: correct, faulty: faulty, passesCorrect: check(correct), passesFaulty: check(faulty))
}

func countSeparations(_ results: [String]) {
    for name in ["separating", "blind", "noisy", "inverted"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}

In this lesson too, the core is only used. The separation names are the same; only what the checks do changes.

Passing the Dependency from Outside

A view model without injection builds its client itself. In this version there are two ways to check it, and both are bad: going out to the real network, or finding a way to replace the client that was built.

import Foundation

// Dependency built inside — KAYNAK
final class ProductListViewModel {
    private let service = NetworkProductService(session: URLSession.shared)
    private(set) var items: [String] = []

    func refresh() async {
        guard let reply = await service.fetch() else { return }
        items = reply
    }
}

This fragment is not run; the session and the network depend on the platform, and the lesson claims no output for them. Its structure is enough: service is fixed and built inside. To check this model’s refresh call, the network has to be up, the server has to give its reply at that moment, and the reply has to be what the check expects. None of the three is in the check’s hands.

There are three ways to pass a dependency from outside. The most direct is initializer injection: the dependency arrives as a parameter when the object is built. In property injection it is assigned after the object is built; in function parameter injection it is given only in the call to the function that needs it. This lesson uses the initializer, because the dependency does not change over the object’s lifetime.

import Foundation

// Dependency passed from outside — KAYNAK
protocol ProductService: Sendable {
    func fetch() async -> [String]?
}

struct NetworkProductService: ProductService {
    let session: URLSession

    func fetch() async -> [String]? {
        guard let url = URL(string: "https://example.com/products") else { return nil }
        guard let (data, _) = try? await session.data(from: url) else { return nil }
        return try? JSONDecoder().decode([String].self, from: data)
    }
}

final class InjectedListViewModel {
    private let service: any ProductService
    private(set) var items: [String] = []

    init(service: any ProductService) { self.service = service }
}

This fragment is not run either. What matters is the protocol: the view model no longer knows the real client, it knows the ProductService contract. The real client conforms to that contract, and the fake dependency in a check conforms to it too. The view model cannot tell the two apart.

What a Fake Dependency Imitates

The model builds the protocol and two fake dependencies in plain Swift. OfflineService imitates the case where there is no network: it returns nil on every call. StubService returns a reply given in advance; it is a stub that gives a fixed reply when called. The product list model takes one of three paths in refresh: if the request fails, the list is kept; if the reply is empty, the correct version also keeps the list and the faulty version clears it; if the reply has items, the list is replaced with the reply. The fault is only in the second path. Catalog.swift is a helper file and prints no output.

// Catalog.swift — product list model and fake dependencies
protocol ProductService: Sendable {
    func fetch() async -> [String]?
}

struct OfflineService: ProductService {
    func fetch() async -> [String]? { nil }
}

struct StubService: ProductService {
    let reply: [String]
    func fetch() async -> [String]? { reply }
}

struct ProductListModel {
    private(set) var items: [String]
    let service: any ProductService
    let faulty: Bool

    mutating func refresh() async {
        guard let reply = await service.fetch() else { return }
        if reply.isEmpty && !faulty { return }
        items = reply
    }
}

func refreshedItems(start: [String], service: any ProductService, faulty: Bool) async -> String {
    var model = ProductListModel(items: start, service: service, faulty: faulty)
    await model.refresh()
    return model.items.isEmpty ? "empty" : model.items.joined(separator: ",")
}

Three checks are applied to the same two versions. The first stands in for a check tied to the real network client. Because the network is closed in the sandbox, the client in this check is modeled with OfflineService, and the check expects the server’s list of three products. The second works with a stub that returns an empty reply. The third imitates a network error and expects the list to be kept.

// main.swift — three checks, three different fake dependencies
let start = ["notebook", "pen"]

let goodReal = await refreshedItems(start: start, service: OfflineService(), faulty: false)
let badReal = await refreshedItems(start: start, service: OfflineService(), faulty: true)
let goodEmpty = await refreshedItems(start: start, service: StubService(reply: []), faulty: false)
let badEmpty = await refreshedItems(start: start, service: StubService(reply: []), faulty: true)

let real = contrast("real-network-client", correct: goodReal, faulty: badReal) { $0 == "notebook,pen,eraser" }.describe()
let empty = contrast("empty-reply-stub", correct: goodEmpty, faulty: badEmpty) { $0 == "notebook,pen" }.describe()
let failure = contrast("error-reply-stub", correct: goodReal, faulty: badReal) { $0 == "notebook,pen" }.describe()

countSeparations([real, empty, failure])
real-network-client | correct=notebook,pen/fail faulty=notebook,pen/fail -> noisy
empty-reply-stub | correct=notebook,pen/pass faulty=empty/fail -> separating
error-reply-stub | correct=notebook,pen/pass faulty=notebook,pen/pass -> blind
separating: 1
blind: 1
noisy: 1
inverted: 0

The output was run three times in the sandbox on Swift 6.4 and is identical in all three. The three checks gave three different separations: one separating, one blind, one noisy.

real-network-client expects the list of three products that will come from the server. Because there is no network in the sandbox, the client cannot give a reply, the list stays as it was, and notebook,pen comes out on both versions. The check fails on both versions, so it is noisy. It does not catch the fault; it fails not because of the fault but because the check is tied to the network. The same noise exists in a real environment too: if the server is slow, unreachable or gives a different reply, the check turns red, and the person looking at the red blames the network instead of the code.

empty-reply-stub looks at whether the list is kept, and the stub returns an empty reply. The correct version gives notebook,pen and passes; the faulty version clears the list, gives empty and fails. The check is separating. The difference here is that the dependency is passed from outside: the path where the fault lives, the empty reply, can be triggered only by a check that controls the dependency.

error-reply-stub imitates a network error and expects the list to be kept. In the error branch both versions keep the list, so the check passes on both versions and is blind. The fault is in the empty reply branch, not in the error branch. When a fake dependency imitates the wrong path, the check looks right and misses the fault.

Assumptions

AR1. The real network client is not measured. The network is closed in the sandbox; instead of the real client, the “no reply arrives” case is modeled with OfflineService, and the check for this case is noisy. How the same check would behave in a real environment depends on the server, and this lesson gives it no value.

AR2. The model’s version of the ProductService protocol is the same as the real client’s contract: a list of replies, or nil. The real client has error types, authentication and retries; the model reduces them to a single nil.

AR3. The reply is a list of product names. Paging, ordering and product fields do not enter the model.

The Bounding Measurement

What a fake dependency imitates determines the separation. If the stub returns the same full reply on every call, the faulty path never runs. The same two versions are tried with two stubs: one always returns a full reply of three products, the other returns an empty reply.

// main.swift — the same two versions, two different stubs
let start = ["notebook", "pen"]
let full = ["notebook", "pen", "eraser"]

let goodFull = await refreshedItems(start: start, service: StubService(reply: full), faulty: false)
let badFull = await refreshedItems(start: start, service: StubService(reply: full), faulty: true)
let goodNone = await refreshedItems(start: start, service: StubService(reply: []), faulty: false)
let badNone = await refreshedItems(start: start, service: StubService(reply: []), faulty: true)

let onFull = contrast("full-reply-stub", correct: goodFull, faulty: badFull) { $0 == "notebook,pen,eraser" }.describe()
let onNone = contrast("empty-reply-stub", correct: goodNone, faulty: badNone) { $0 == "notebook,pen" }.describe()

countSeparations([onFull, onNone])
full-reply-stub | correct=notebook,pen,eraser/pass faulty=notebook,pen,eraser/pass -> blind
empty-reply-stub | correct=notebook,pen/pass faulty=empty/fail -> separating
separating: 1
blind: 1
noisy: 0
inverted: 0

The output is identical across three runs. When the stub always returns a full reply, both versions replace the list with the reply and the check is blind; the path where the fault lives never runs. When the stub returns an empty reply, the same kind of check is separating. The check did not change; the reply the fake dependency imitates changed. This points to the most common mistake with fake dependencies: building the stub for the happy path. On the happy path nothing breaks, the check stays green, and the faulty path reaches the user with the next real empty reply.

Summary

  • Dependency injection means an object receives the dependency it needs from outside; the most common way is to pass it through the initializer.
  • A check tied to the real network client depends on the network and the server; when the network gives no reply, it fails on both versions and is noisy.
  • A check with a stub that returns an empty reply triggers the path where the fault lives and is separating.
  • A stub that imitates an error reply is blind if the fault is in the empty reply branch: the three checks gave one separating, one blind and one noisy.
  • If the stub always returns a full reply, the faulty path does not run and the check stays blind.
  • This lesson’s separation: noisy.

Next Step

Passing the dependency from outside separated the objects from each other; but if they all stand on the same target, every object can see every object. In the fifth lesson, Modularization will split the code into packages and put the question of which package may depend on which into a rule. There the fault will be one feature module binding directly to another feature module, and the check that looks at whether the build passes will stay blind to this fault.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close