Software Design

SwiftUI Dependency Injection: From EnvironmentObject to Protocol DI

As your SwiftUI app grows, the phrase dependency injection alone can make your shoulders tense.

3 min read
Cover image for SwiftUI Dependency Injection: From EnvironmentObject to Protocol DI

As your SwiftUI app grows, the phrase dependency injection alone can make your shoulders tense.

EnvironmentObject may seem sufficient, but even a modestly larger app starts to creak in places.

This article walks through SwiftUI dependency injection in the order I organized it: from EnvironmentObject to protocol-based DI (Dependency Injection).

EnvironmentObject is a tool for sharing values between screens. If you want services that are truly easy to swap, abstract them behind protocols and inject them.


What exactly is dependency injection?

Many articles explain dependency injection in complicated ways, but this is how I describe it.

An object receives the tools it uses from outside instead of creating them itself.

For example, if a ViewModel directly news up a network service internally, you must hit the real server when testing later.

But if you provide that service from outside, you can supply a fake service during tests.

That is all there is to it. The concept itself is really simple.


I started with EnvironmentObject

When you learn SwiftUI, EnvironmentObject is one of the first things you encounter.

Inject it once in a parent view, and any child view can access it, so it felt incredibly convenient at first.

Inject it once at the top level as shown below, then access it directly from child views.

class UserStore: ObservableObject {
    @Published var name = ""
}
// Inject at the top level
ContentView().environmentObject(UserStore())
// From any child view
@EnvironmentObject var store: UserStore

For app-wide shared values such as login state or theme, nothing beats it.

Forget one injection, and compilation stays quiet while runtime crashes.
Forget one injection, and compilation stays quiet while runtime crashes.

The problem appeared as the number of screens grew. Forget to inject an EnvironmentObject, and the app simply crashes at runtime. Compilation says nothing.

Since it is tied to the view hierarchy, it felt awkward to use in pure logic outside views.


So I moved to protocol DI

This is where things get serious. Define services as protocols rather than concrete types.

Here is an example that uses one protocol for both the real implementation and a test fake.

protocol WeatherService {
    func fetch() async -> Int
}
struct RealWeather: WeatherService {
    func fetch() async -> Int { 23 }
}
struct MockWeather: WeatherService {
    func fetch() async -> Int { 999 } // Fixed value for testing
}

The ViewModel does not know about RealWeather. It only knows the contract called WeatherService.

The ViewModel sees only the protocol
The ViewModel sees only the protocol

Use RealWeather in the app and MockWeather in tests, and you can change behavior without changing a single line of code.

After switching to this structure, writing test code became much easier for me.

In practice, just swap MockWeather for RealWeather
In practice, just swap MockWeather for RealWeather

EnvironmentObject vs. protocol DI: when should you use each?

They are not alternatives; they serve different roles. I summarized my practical criteria in a table.

Category EnvironmentObject Protocol DI
Primary purpose Share state between screens Swap logic dependencies
View hierarchy coupling Strong (tied to views) None (independent of views)
Test replacement Difficult Very easy
When injection is missing Runtime crash Prevented at compile time

In practice, the most reasonable combination was to use EnvironmentObject for global state and protocol DI for services that need to be swapped.


Summary

If your app is still small, EnvironmentObject is enough. But if you take testing seriously, get into the habit of wrapping services in protocols early. Your future self will thank you.