# 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:

```swift
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.

```swift
// 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:

```swift
@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:

```swift
// 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:

```swift
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:

```swift
// ❌ 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

```swift
@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.
