When is a struct of closures a better dependency than a protocol, and what do you give up?

15.8.1 · L5 · Interview

Predict first

A failing client is overridden one endpoint at a time. What does this program print?

struct ProfileClient: Sendable {
    var load: @Sendable (Int) async throws -> String
    var save: @Sendable (Int, String) async throws -> Void
}
extension ProfileClient {
    static let live = ProfileClient(
        load: { id in "live-user-\(id)" },
        save: { id, name in print("saving \(name) for \(id) over the network") })
    static let failing = ProfileClient(
        load: { _ in fatalError("unexpected load") },
        save: { _, _ in fatalError("unexpected save") })
}
protocol ProfileService: Sendable { func load(_ id: Int) async throws -> String; func save(_ id: Int, _ name: String) async throws }
struct OnlyLoadFake: ProfileService {
    func load(_ id: Int) async throws -> String { "fake-\(id)" }
    func save(_ id: Int, _ name: String) async throws { fatalError("unexpected save") }   // protocol: must write every requirement
}

var client = ProfileClient.failing
client.load = { id in "fake-\(id)" }            // override just the one endpoint under test
print(try await client.load(7))
var spy = client
nonisolated(unsafe) var saved: [String] = []
spy.save = { _, name in saved.append(name) }
try await spy.save(7, "Asha")
print("saved:", saved)
print(try await OnlyLoadFake().load(1))
  • fake-7 / saved: ["Asha"] / fake-1
  • It crashes on the first line because save is a failing stub
  • fake-7 / saved: [] / fake-1

Mental model. A protocol is a fully wired control panel: to fake it you must supply every dial. A struct of closures is a plug board: start with everything unplugged (and alarmed) and patch in just the one cable the test needs.

A protocol with five methods needs a new conforming type for every test double, and each double must implement all five, including the four the test never calls. A struct of closures (a “witness”, or “client”) models the same dependency as struct ProfileClient { var load: @Sendable (Int) async throws -> String; var save: … } with static instances for .live and a .failing one whose every endpoint traps. A test copies the failing client and overrides only the endpoint it exercises: client.load = { "fake-\($0)" }. Anything else the code touches fails loudly instead of silently returning a default. In the check, the load override returns “fake-7”, a spy copy records the saved name, and the protocol version has to spell out a full fake type, writing a trapping save by hand. Closures are values, so clients can be modified, composed and wrapped (log every call, add retries) without subclassing, and a feature depends on exactly the endpoints it declares, which keeps dependency surfaces small.

What you give up: no nominal identity (you cannot tell which implementation you have, or conform it to other protocols), weaker tooling (go-to-definition lands on a stored closure, stack traces show closure #1), no associatedtype or stored state without capturing it, and closure capture rules to remember (an escaping closure that captures self can create a cycle; a @Sendable closure cannot capture mutable state, which is why the spy uses a nonisolated(unsafe) variable in a single-threaded check). A protocol remains better for a dependency with real identity or many cohesive operations, for library boundaries where you want conformances, and wherever the team prefers nominal types. A middle path: a protocol for the public contract and a struct-of-closures adapter used inside tests. Either way, the principle from Q355 stands: depend on a small interface, supply it at the composition root.

Overriding load and save independently leaves every other endpoint failing; the protocol fake had to be written in full.

Common traps

  • A .failing default that silently returns empty values. The test passes for the wrong reason; make unexpected calls fail loudly.
  • Capturing self strongly in a stored closure. A client owned by an object that it also captures is a retain cycle.

They might follow up with

  • Is it still dependency injection? Yes; only the shape of the dependency changed.
  • How do I mix both? Keep the protocol at module boundaries and build a closure-based adapter for tests, or the other way round.

Related questions

  • 15.8.2 How do you inject a protocol that has an associated type: primary associated types, any versus some, and…
  • 15.8.3 How do you scope dependencies by lifetime (app, session, screen), and what leaks when a longer-lived object…
  • 15.2.2 How do you do dependency injection in Swift, and when do you need a container or framework?
  • 22.4.1 What is the difference between dummies, stubs, fakes, spies and mocks, and how do you decide which to use?

How do you inject a protocol that has an associated type: primary… →