Dependency Inversion in SwiftUI: Programming to Protocols

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.
The Problem: High-Level Code Depending on Low-Level Details
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
scheduleReminderwithout triggering real system notifications.Swapping
UserNotificationsfor a different delivery mechanism (push, in-app, analytics event) requires modifying the ViewModel.The ViewModel accumulates knowledge it shouldn't have.
Step 1 — Define an Abstraction Owned by the High-Level Module
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.
Step 2 — Invert the Dependency in the ViewModel
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.
Step 3 — Implement the Protocol in the Infrastructure Layer
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.
Step 4 — Test the ViewModel Without Any Framework
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.
Recognising DIP Violations
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.
DIP Applied Across the Series
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.
Wiring at the Composition Root
@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.
What You Gain
| 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 |
Summary
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.





