Every class needs things to do its job. An oven ViewModel needs a network client to call the API. A fridge ViewModel needs to know when a door has been open too long, which means it needs a way to send notifications. A login screen needs something to authenticate against.

The naive way to get those things is to reach out and grab them directly:

class OvenViewModel {
    func toggleOven(deviceId: String) async {
        // Hard-wired — goes straight to the real server
        let result = try await APIService.shared.setOvenPower(...)
    }
}

This works fine in the running app. The problem shows up the moment you try to write a test, or the moment a teammate needs to work on the UI before the server is ready.

If OvenViewModel always calls APIService.shared, you can’t test it without a live server. You can’t write a test that simulates a 401 Unauthorized response. You can’t verify error handling without actually breaking the server. The class has reached outside itself and grabbed a concrete dependency — it owns that decision and nothing can change it from the outside.

This is called tight coupling, and it’s the enemy of testable code.


Dependency Injection is the practice of passing a class’s dependencies in from the outside rather than letting it create or reach for them itself.

Instead of OvenViewModel knowing about APIService, it knows about a protocol — a description of what any API service must be able to do. The concrete implementation is handed in at creation time:

// The protocol describes the contract — not an implementation
protocol APIServiceProtocol: AnyObject {
    func setOvenPower(deviceId: String, compartment: String?, isOn: Bool) async throws -> OvenState
    // ... other methods
}

// The ViewModel only knows about the protocol
class OvenViewModel {
    private let api: APIServiceProtocol

    init(api: APIServiceProtocol) {
        self.api = api
    }
}

Now the ViewModel has no idea whether it’s talking to a real server or a fake one. In production you pass in APIService.shared. In a test you pass in a MockAPIService that returns whatever you tell it to.

This is called the Dependency Inversion Principle — depend on abstractions, not on concrete implementations. It’s the D in SOLID.

The payoff is immediate:

  • Tests run without a server. Your mock returns success, failure, or an auth error on demand.
  • UI development is unblocked. A designer can work with a mock that returns fixture data while the real API is still being built.
  • Behaviour is explicit. When you read an init, you can see exactly what a class needs to do its job.

This app has three injectable dependencies, each backed by a protocol and a test double.

APIServiceProtocol covers all 16 REST endpoints. Both APIService (real, hits the Node server) and MockAPIService (test double) conform to it:

protocol APIServiceProtocol: AnyObject {
    var authToken: String? { get set }
    func getDevices() async throws -> [DeviceInfo]
    func setOvenPower(deviceId: String, compartment: String?, isOn: Bool) async throws -> OvenState
    func startDishwasher(deviceId: String, cycle: String) async throws -> DishwasherState
    // ... 13 more
}

MockAPIService stores stubbable Result properties and records every call:

final class MockAPIService: APIServiceProtocol {
    var setOvenPowerResult: Result<OvenState, Error> = .success(.defaultOvenState)
    var setOvenPowerCalls: [(deviceId: String, compartment: String?, isOn: Bool)] = []

    func setOvenPower(deviceId: String, compartment: String?, isOn: Bool) async throws -> OvenState {
        setOvenPowerCalls.append((deviceId, compartment, isOn))
        return try setOvenPowerResult.get()
    }
}

WebSocketProviding lets you inject a fake WebSocket that you can fire events on manually in tests:

protocol WebSocketProviding: AnyObject {
    var onDeviceUpdate: ((String, String, String?, Data) -> Void)? { get set }
    func connect(token: String)
    func subscribe(deviceIds: [String])
}

TelemetryService lets tests assert that the right events were recorded without any actual logging:

let telemetry = MockTelemetryService()
let vm = OvenViewModel(api: mock, telemetry: telemetry)
await vm.toggleOven(deviceId: "oven1")

XCTAssertTrue(telemetry.contains(.ovenPowerChanged(deviceId: "oven1", compartment: nil, isOn: true)))

The production app never has to change any call sites. All ViewModels use default parameter values so the real dependencies are used automatically:

init(
    api: APIServiceProtocol = APIService.shared,
    webSocket: WebSocketProviding = WebSocketService.shared,
    telemetry: TelemetryService = OSLogTelemetryService.shared
) { ... }

In tests you provide mocks. In production you get the defaults. No configuration required, no separate factory, no DI container. Simple, explicit, and easy to follow.

On to lesson 3 — Combine… coming soon


One response to “2. Dependency Injection — Don’t Hard-Wire Your Dependencies”

Leave a Reply

Your email address will not be published. Required fields are marked *