Dependency Inversion in SwiftUI: Programming to Protocols

Search for a command to run...

No comments yet. Be the first to comment.
Every article in this series has ended with the same instruction: wire everything at the Composition Root. We've used it as the place where RemoteFeedRepository meets CachingFeedRepository, where User

Unit tests verify logic. UI tests verify user flows. But neither catches the moment a refactor silently shifts a layout, changes a font, or breaks a dark mode style. Snapshot testing fills that gap. O

In previous articles, we built a CachingFeedRepository by writing the implementation first and tests after. That works — but it leaves a subtle gap: the tests are shaped by the implementation you alre

The Single Responsibility Principle (SRP) states that a class should have one reason to change. It's the first letter of SOLID, and arguably the one most frequently violated in SwiftUI projects. The c

Of all the SOLID principles, the Dependency Inversion Principle (DIP) is the one that most directly enables testability and long-term flexibility. Yet it's also the most commonly misunderstood.
DIP states two things:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
In plain terms: the parts of your app that contain business logic should never import or instantiate the parts that deal with infrastructure (network, disk, third-party SDKs). The dependency points the other way — toward an abstraction that both sides agree on.
This article shows what violating DIP looks like in SwiftUI, why it matters, and how to systematically invert the dependencies in a real feature.
Consider a notification feature. The ViewModel decides when to send a notification. A service decides how to send it. A common first pass looks like this:
import UserNotifications // ⚠️ Infrastructure import in a high-level module
@MainActor
final class ReminderViewModel: ObservableObject {
@Published var isScheduled = false
func scheduleReminder(title: String, in seconds: TimeInterval) async {
let content = UNMutableNotificationContent()
content.title = title
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: seconds,
repeats: false
)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
do {
try await UNUserNotificationCenter.current().add(request)
isScheduled = true
} catch {
isScheduled = false
}
}
}
The ViewModel imports UserNotifications and instantiates UNUserNotificationCenter directly. The high-level business decision (when to schedule) is fused with the low-level delivery detail (how to schedule).
Consequences:
You cannot test scheduleReminder without triggering real system notifications.
Swapping UserNotifications for a different delivery mechanism (push, in-app, analytics event) requires modifying the ViewModel.
The ViewModel accumulates knowledge it shouldn't have.
The key insight of DIP: the abstraction belongs to the high-level module, not the low-level one.
The ViewModel defines what it needs. The infrastructure provides it.
// Domain/Notifications/NotificationScheduler.swift
protocol NotificationScheduler {
func schedule(title: String, in seconds: TimeInterval) async throws
}
This protocol lives in the Domain layer. It expresses a business need in business language — no mention of UNUserNotificationCenter, UNMutableNotificationContent, or any framework type.
The ViewModel now depends on the abstraction, not the concrete type:
@MainActor
final class ReminderViewModel: ObservableObject {
@Published private(set) var isScheduled = false
private let scheduler: NotificationScheduler
init(scheduler: NotificationScheduler) {
self.scheduler = scheduler
}
func scheduleReminder(title: String, in seconds: TimeInterval) async {
do {
try await scheduler.schedule(title: title, in: seconds)
isScheduled = true
} catch {
isScheduled = false
}
}
}
No framework imports. No concrete types. The ViewModel is now a pure business logic coordinator — it decides when and delegates how entirely to whoever implements NotificationScheduler.
The concrete implementation lives in the Data/Infrastructure layer. It can import anything it needs without polluting the Domain or Presentation layers:
// Infrastructure/Notifications/UserNotificationScheduler.swift
import UserNotifications
final class UserNotificationScheduler: NotificationScheduler {
func schedule(title: String, in seconds: TimeInterval) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: seconds,
repeats: false
)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
try await UNUserNotificationCenter.current().add(request)
}
}
UserNotifications is now confined to a single, replaceable class. The rest of the codebase has no idea it exists.
With the abstraction in place, testing is straightforward:
final class ReminderViewModelTests: XCTestCase {
func test_scheduleReminder_setsIsScheduledOnSuccess() async {
let (sut, scheduler) = makeSUT()
await sut.scheduleReminder(title: "Buy groceries", in: 3600)
XCTAssertTrue(sut.isScheduled)
XCTAssertEqual(scheduler.scheduledTitles, ["Buy groceries"])
}
func test_scheduleReminder_clearsIsScheduledOnFailure() async {
let (sut, _) = makeSUT(schedulerResult: .failure(anyError()))
await sut.scheduleReminder(title: "Any", in: 3600)
XCTAssertFalse(sut.isScheduled)
}
func test_scheduleReminder_passesCorrectDuration() async {
let (sut, scheduler) = makeSUT()
await sut.scheduleReminder(title: "Standup", in: 900)
XCTAssertEqual(scheduler.scheduledSeconds, [900])
}
// MARK: - Helpers
private func makeSUT(
schedulerResult: Result<Void, Error> = .success(())
) -> (ReminderViewModel, NotificationSchedulerSpy) {
let scheduler = NotificationSchedulerSpy(result: schedulerResult)
let sut = ReminderViewModel(scheduler: scheduler)
return (sut, scheduler)
}
private func anyError() -> Error { NSError(domain: "test", code: 0) }
}
// MARK: - Test Double
private class NotificationSchedulerSpy: NotificationScheduler {
private let result: Result<Void, Error>
private(set) var scheduledTitles: [String] = []
private(set) var scheduledSeconds: [TimeInterval] = []
init(result: Result<Void, Error>) { self.result = result }
func schedule(title: String, in seconds: TimeInterval) async throws {
scheduledTitles.append(title)
scheduledSeconds.append(seconds)
try result.get()
}
}
No UserNotifications. No simulator. No permission dialogs. The entire ViewModel is verified by fast, deterministic unit tests.
A DIP violation always looks like one of these patterns:
// ❌ Importing infrastructure in a high-level module
import UserNotifications
import FirebaseAnalytics
import CoreData
import Alamofire
// ❌ Instantiating concrete types inside business logic
let service = UserNotificationScheduler()
let tracker = FirebaseAnalyticsTracker()
let store = CoreDataFeedStore()
// ❌ Using a singleton from a third-party framework
Analytics.shared.track(...)
URLSession.shared.data(from:...)
Each of these fuses a business decision with an infrastructure detail. Invert it by extracting a protocol at the boundary.
Looking back at the articles in this series, DIP is present in every pattern we've used:
| Article | Protocol (abstraction) | Concrete type (detail) |
|---|---|---|
| Dependency Injection | FeedRepository |
RemoteFeedRepository |
| Clean Architecture | FeedRepository |
RemoteFeedRepository |
| Decorator Pattern | FeedRepository |
CachingFeedRepository wrapping RemoteFeedRepository |
| SRP | LoadProfileUseCase |
RemoteLoadProfileUseCase |
| TDD Cache Layer | FeedStore |
CodableFeedStore |
| This article | NotificationScheduler |
UserNotificationScheduler |
DIP is not a separate technique — it's the structural backbone that makes every other pattern composable, testable, and replaceable.
@main
struct ReminderApp: App {
var body: some Scene {
WindowGroup {
ReminderView(
viewModel: ReminderViewModel(
scheduler: UserNotificationScheduler()
)
)
}
}
}
The concrete type appears once, at the entry point. Everything else in the codebase works with the protocol.
| Tight Coupling (no DIP) | Inverted Dependency (DIP) |
|---|---|
ViewModel imports UserNotifications |
ViewModel imports nothing |
| Changing delivery mechanism modifies business logic | Only the concrete class changes |
| Tests require real system APIs | Tests use a fast spy |
| Reusing the ViewModel in a different context is impossible | Drop in any conforming type |
| Third-party SDK updates ripple across the codebase | Impact contained to one class |
The Dependency Inversion Principle is the practice of protecting business logic from infrastructure details by pointing all dependencies toward abstractions, never toward concretions.
In SwiftUI:
Define a protocol in the Domain layer that expresses what the business needs, in business language.
Inject that protocol into the ViewModel through the initialiser — no singletons, no direct instantiation.
Implement the protocol in the Infrastructure layer, where framework imports are confined.
Wire the concrete type at the Composition Root — the only place where all layers meet.
Test with a spy or stub — no framework, no side effects, no flakiness.
Once your high-level modules depend only on protocols they own, infrastructure becomes a plug-in — swappable, mockable, and completely isolated from the logic that matters most.