§ 04 · 2026 · 05 · 08 · DI
Dependency injection in SwiftUI without third-party libraries
SwiftUI's @Environment wasn't designed for services. @EnvironmentObject crashes at runtime when you forget. There's a third option that's been around since RIBs and works cleanly with Swift 6 actors.
SwiftUI ships two dependency-injection-ish mechanisms. Both have sharp edges.
The two SwiftUI mechanisms
@Environment works through a key path on EnvironmentValues. You define a key with a default, register it, then read with @Environment(\.myKey). It's typed, compile-checked, and reasonable for small leaf values like a color scheme or a locale. It's awkward for anything stateful, and the default-value requirement makes it tedious for services that don't have one.
@EnvironmentObject threads a reference-type observable through the view tree by class. You write .environmentObject(myService) at some ancestor; descendants read @EnvironmentObject var myService: MyService. It's flexible, but the lookup is by type and the safety is runtime: forget the .environmentObject(...) call and the app crashes with "no ObservableObject of type MyService" the moment a descendant tries to read it. The crash isn't even at app launch — it's the first time the affected view tries to render.
Neither is what you want for the things you actually need to inject: networking, auth, persistence, analytics. Those are services, not values. They have one canonical instance per scope. They need to be testable. They shouldn't crash if a developer reorders the view tree.
The pattern: components own services, the tree threads them down
The DI model napkin uses (inherited from Uber's RIBs) is older than SwiftUI by years. Every feature has a Dependency protocol that says what it needs from above. Every feature has a Component class that conforms to its children's dependency protocols. The root component holds the actual service instances; children read them from their parent's component.
protocol HomeDependency: Dependency {
var authService: AuthService { get }
}
final class HomeComponent: Component<HomeDependency>, @unchecked Sendable {
var authService: AuthService { dependency.authService }
}
// The children's Dependency protocols are bridged by extension:
extension HomeComponent: ProfileDependency, FeedDependency {}
Three properties that make this work better than the SwiftUI environment:
- Compile-time checks. If a feature declares
authServicein its dependency protocol and the parent component doesn't satisfy it, the code doesn't compile. No runtime crash. - Explicit interfaces. Every feature lists what it needs in one place — the dependency protocol. Read that protocol and you know what the feature touches.
- Test-friendly. In a test, construct a hand-built component with mock services and pass it to the builder. The feature is exercised the same way as in production.
The bootstrap
The root of the tree — usually inside the scene delegate — instantiates a top-level AppComponent that owns the real service implementations:
final class AppComponent: Component<EmptyDependency>,
LaunchNapkinDependency,
@unchecked Sendable {
let authService: AuthService
init() {
self.authService = URLSessionAuthService(baseURL: API.production)
super.init(dependency: EmptyComponent())
}
}
// In the scene delegate:
let builder = LaunchNapkinBuilder(dependency: AppComponent())
let router = await builder.build(withListener: AppListener())
await router.launch(from: window)
One AuthService instance, one AppComponent, both alive for the app's lifetime. Below the root, each feature constructs its own component from its parent's, declaring what new internal scope it owns. A network service stays a single instance; a per-screen view-model is fresh each time.
What you don't get
This pattern doesn't auto-discover anything. There's no @Inject magic, no annotation processor, no runtime container. If a feature needs a new service, you add it to the dependency protocol and provide it at the root. That sounds like more work; in practice it's the difference between an architecture you can read in a graph diagram and one you can only debug at runtime.
It also doesn't work well with views that aren't part of a napkin tree. If you have a one-off SwiftUI sheet that doesn't justify its own napkin, @Environment is still the right tool. The dependency-tree pattern earns its keep when services need to thread through many screens.
Try it
The RibHouse example app uses this pattern end-to-end with a single service (AuthService). The tutorial walkthrough shows where each piece slots in. If you've worked with Swinject, Resolver, Factory, or any of the runtime containers, the difference is that the type system does the work — the container is just classes you write.