Skip to main content

Command Palette

Search for a command to run...

Dependency Inversion in SwiftUI: Programming to Protocols

Updated
7 min readView as Markdown
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:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.

  2. 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 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.


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:

  1. Define a protocol in the Domain layer that expresses what the business needs, in business language.

  2. Inject that protocol into the ViewModel through the initialiser — no singletons, no direct instantiation.

  3. Implement the protocol in the Infrastructure layer, where framework imports are confined.

  4. Wire the concrete type at the Composition Root — the only place where all layers meet.

  5. 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.