Protocol-Based Network Layer — Real DIP, Free Tests
Lesson 4 wired Pulse to a real API. The view-state called MarketsAPI directly. That works — until you want to:
- Render the failure UI in a SwiftUI Preview without unplugging the WiFi.
- Write a unit test for
WatchlistViewStatethat doesn’t hit the network. - Swap CoinGecko for a different upstream (CoinMarketCap, your own backend) without touching the view-state.
- Run a thousand-iteration test against the loading state without DDoS-ing the API.
All four are the same problem: the view-state depends on a concrete type. Fix it by making it depend on a protocol instead, and inject the implementation. SOLID’s Dependency Inversion Principle is what this is — high-level code depends on abstractions, low-level code provides them.
Today’s refactor: introduce MarketsRepository, a 1-method protocol. LiveMarketsRepository wraps the network. MockMarketsRepository returns fixtures with configurable delay and an optional failure. The view-state takes either via init. One refactor, four problems disappear.
The protocol
A repository in this codebase means “an object that fetches a thing.” Here, the thing is [MarketEntry]. The protocol surface is one method.

Three small but deliberate choices:
Sendableconformance. Without it, Swift 6’s strict concurrency complains the moment you cross actor boundaries. We’re going to call this from@MainActorview-states; the implementation runs on whichever actor it likes.Sendabledocuments that the value is safe to ship across actors.- The protocol takes simple inputs (
Int) and returns the domain type ([MarketEntry]). The protocol doesn’t leak that the implementation hits HTTP — that’s a detail of the live concretion. ISP applied — the smallest interface the view-state needs. LiveMarketsRepositorywrapsMarketsAPIinstead ofMarketsAPIitself conforming to the protocol directly. This indirection is cheap and gives you a place to add caching, logging, or analytics that’s specific to “live calls” without touching the raw network code.
The view-state will only ever see MarketsRepository. The fact that production fetches over HTTP, that previews use fixtures, that tests use a fake — it doesn’t know and doesn’t care. That’s the win.
The mock
Mock implementations are easy to write badly — usually as a class with a bunch of “if it’s the test we expect” hardcoded behavior. Don’t do that. The mock should be configurable as a value.

Three things make this mock useful:
- Three knobs: fixtures, delay, failure. The tester writes
MockMarketsRepository()and gets a sensible default (small fixture set, 400ms delay, no error). They override what they need:MockMarketsRepository(delay: .seconds(3))to test the spinner;MockMarketsRepository(failure: APIError.server(status: 503))to test the error UI;MockMarketsRepository(fixtures: [])to test the empty state. - It’s a
struct. Mocks should be values, not classes. No shared state surprises across tests, no need forsetUp/tearDownrituals. MarketEntry.previewSetis a static fixture exposed on the model. Both the mock and Xcode#Previewblocks consume it. One source of truth for “what does sample data look like” — change it once, every preview and test reflects it.
This is the kind of mock that becomes a building block. We’ll reuse it in lesson 7 (UI state modeling), lesson 9 (cache strategy), and lesson 15 (testing). The investment compounds.
The view-state takes the protocol
The init is the one line that matters. Default to LiveMarketsRepository so production code compiles unchanged; let tests and previews override.

The pattern in three lines:
private let repository: any MarketsRepository— the view-state holds an existential.anylets us store a value of any type that conforms to the protocol. Swift’s type system tracks the abstraction, not the concretion.init(repository: any MarketsRepository = LiveMarketsRepository())— default arg means existing call sites (WatchlistViewState()) still work. Previews and tests pass an explicit mock.- Two
#Previewblocks demonstrate the success path and the failure path side-by-side. Open the file in Xcode preview canvas, see both states without touching the simulator. This is the leverage — every UI state, every render path, visible at edit time.
A pure-Swift unit test (lesson 15) can construct WatchlistViewState(repository: MockMarketsRepository(fixtures: ...)) and assert on the resulting entries. No XCUITest, no simulator boot, no flake.
A trap I see weekly: the singleton repository
The vibe-coded version of “share the API across screens”:
class MarketsAPI {
static let shared = MarketsAPI() // singleton
}
@Observable @MainActor
final class WatchlistViewState {
private let api = MarketsAPI.shared
func load() async { /* ... */ }
}
Compiles. Runs. Cannot be tested without globally mutating the singleton. Cannot have two view-states with different configurations (e.g., one for live data, one for “demo mode”). Every preview shares the same instance, so previewing the failure state means actually breaking the network.
The protocol-with-init pattern eliminates the trap. No singleton anywhere. Production wires up the real repo at the App level (or accepts the default). Tests and previews wire up mocks. Three lines of code, infinite testability.
If you really need a single shared instance for legitimate reasons (e.g., a session-scoped auth token), wire it as an environment value — same pattern as Foundations lesson 8. Singletons are almost never the right tool in 2026 SwiftUI.
What it looks like running

Same screen as lesson 4. The user sees zero difference. That’s the point of the refactor — the abstraction is invisible at runtime, valuable everywhere else (previews, tests, future swaps). The ones who win from this work aren’t users; they’re future-you and the next dev who joins the project.
Takeaway
Protocol on top, swap implementations underneath. LiveMarketsRepository for prod, MockMarketsRepository for previews and tests, view-state takes either via init. No singletons. DIP applied; ISP respected; LSP because both implementations satisfy the same contract. Three of five SOLID principles, one refactor.
Next lesson: ABNetworking. The open-source production networking layer we built for our banking apps — and why for production-scale apps you use it (or something like it) instead of rolling URLSession yourself.
NativeFirst Team
EditorialThe NativeFirst team — engineers and designers building native Apple apps and writing the courses we wish we had when we started.
Comments
Leave a comment