How to Build Dependency Injection in SwiftUI
Define a protocol for your service, create an EnvironmentKey with a default value, extend EnvironmentValues, and read it with @Environment in any child view. Swap the real implementation for a mock by calling .environment(\\.analyticsService, MockAnalytics()) on any view or scene.
// 1. Protocol
protocol AnalyticsService { func track(_ event: String) }
// 2. Key + default
struct AnalyticsKey: EnvironmentKey {
static let defaultValue: any AnalyticsService = NoOpAnalytics()
}
// 3. EnvironmentValues extension
extension EnvironmentValues {
var analyticsService: any AnalyticsService {
get { self[AnalyticsKey.self] }
set { self[AnalyticsKey.self] = newValue }
}
}
// 4. Read in a view
struct MyView: View {
@Environment(\.analyticsService) private var analytics
var body: some View {
Button("Buy") { analytics.track("buy_tapped") }
}
}
Full implementation
The pattern below defines a thin AnalyticsService protocol, a live implementation, a silent no-op for previews/tests, and a mock that records calls so you can assert on them in unit tests. The service flows through SwiftUI's environment tree from the @main App all the way to deeply nested leaf views — no singletons, no global state.
import SwiftUI
// MARK: - Protocol
protocol AnalyticsService: Sendable {
func track(_ event: String, parameters: [String: String])
}
extension AnalyticsService {
// Convenience overload – no parameters required
func track(_ event: String) {
track(event, parameters: [:])
}
}
// MARK: - Implementations
struct LiveAnalytics: AnalyticsService {
func track(_ event: String, parameters: [String: String]) {
// Replace with your real analytics SDK call
print("[Analytics] \(event) \(parameters)")
}
}
struct NoOpAnalytics: AnalyticsService {
func track(_ event: String, parameters: [String: String]) { }
}
@Observable
final class MockAnalytics: AnalyticsService {
private(set) var events: [(String, [String: String])] = []
func track(_ event: String, parameters: [String: String]) {
events.append((event, parameters))
}
}
// MARK: - Environment Key
private struct AnalyticsKey: EnvironmentKey {
static let defaultValue: any AnalyticsService = NoOpAnalytics()
}
extension EnvironmentValues {
var analyticsService: any AnalyticsService {
get { self[AnalyticsKey.self] }
set { self[AnalyticsKey.self] = newValue }
}
}
// MARK: - Root injection (App entry point)
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.analyticsService, LiveAnalytics())
}
}
}
// MARK: - Consumer view
struct ProductDetailView: View {
let product: String
@Environment(\.analyticsService) private var analytics
var body: some View {
VStack(spacing: 16) {
Text(product)
.font(.title2.bold())
Button("Add to Cart") {
analytics.track("add_to_cart", parameters: ["product": product])
}
.buttonStyle(.borderedProminent)
.accessibilityLabel("Add \(product) to cart")
}
.padding()
.onAppear {
analytics.track("product_viewed", parameters: ["product": product])
}
}
}
// MARK: - Preview (uses NoOp by default, or inject Mock)
#Preview("Live (no-op)") {
ProductDetailView(product: "Pro Subscription")
}
#Preview("With mock") {
let mock = MockAnalytics()
return ProductDetailView(product: "Soarias License")
.environment(\.analyticsService, mock)
}
How it works
-
Protocol as contract.
AnalyticsServiceis a protocol markedSendable, so any conforming type can be safely passed across concurrency boundaries. Views depend only on the protocol — never on a concrete type. -
EnvironmentKey for the plumbing. The private
AnalyticsKeystruct provides adefaultValueofNoOpAnalytics(), meaning any view that forgets to inject a service gets a safe, silent default rather than a crash. -
EnvironmentValues extension for ergonomics. The
var analyticsServicecomputed property onEnvironmentValuesenables the dot-syntax@Environment(\\.analyticsService)in any child view — clean and discoverable. -
Injection at the scene root. Calling
.environment(\\.analyticsService, LiveAnalytics())inMyAppinjects the real service for the full view tree. Override it on any subtree with a different value (great for A/B testing different implementations). -
@Observable mock for testing.
MockAnalyticsis annotated with@Observableso SwiftUI views that render it in previews can react to its state. In unit tests, assert onmock.eventsto verify that tapping "Add to Cart" fired the right event with the right parameters.
Variants
Multiple services in a single container
For larger apps, define a ServiceContainer that holds several services and inject the whole thing via one key. This avoids repeating the boilerplate for every individual service.
struct ServiceContainer {
var analytics: any AnalyticsService = NoOpAnalytics()
var featureFlags: any FeatureFlagService = DefaultFeatureFlags()
}
private struct ServiceContainerKey: EnvironmentKey {
static let defaultValue = ServiceContainer()
}
extension EnvironmentValues {
var services: ServiceContainer {
get { self[ServiceContainerKey.self] }
set { self[ServiceContainerKey.self] = newValue }
}
}
// Inject at root
ContentView()
.environment(\.services, ServiceContainer(
analytics: LiveAnalytics(),
featureFlags: RemoteFeatureFlags()
))
// Read in a view
struct SomeView: View {
@Environment(\.services) private var services
var body: some View {
if services.featureFlags.isEnabled("new_checkout") {
NewCheckoutView()
}
}
}
Scoped injection for subtrees
You can narrow the scope by applying .environment(\\.analyticsService, SandboxAnalytics()) to any intermediate view. Only that view and its descendants receive the new value — ancestors are unaffected. This is useful for onboarding flows that should never fire production events, or for A/B experiments limited to one screen.
Common pitfalls
-
⚠️ Not available below iOS 17: The
@Observablemacro used inMockAnalyticsrequires iOS 17+. If you need iOS 16 support, either remove@Observablefrom the mock (it's only needed for reactive previews) or useObservableObject+@Publishedinstead. -
⚠️ Forgetting the default value:
EnvironmentKey.defaultValuemust always be set. ReturningfatalError()as the default will crash any view used in a Xcode Preview or unit test where no injection has been configured. Use a safeNoOpAnalytics()instead. -
⚠️ Existential overhead with
any Protocol: Usingany AnalyticsServiceintroduces a small existential box allocation. For hot paths (e.g., a service called on every render), prefer a concrete generic or a struct-based approach. For typical analytics or networking calls, the overhead is negligible. -
⚠️ Accessibility: don't inject VoiceOver-critical data via environment. Services like screen readers and accessibility settings are already propagated by SwiftUI through
@Environment(\\.accessibilityReduceMotion)and similar keys. Wrap only your own app-level services; never shadow Apple's built-in environment keys.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement dependency injection in SwiftUI for iOS 17+. Use Environment, EnvironmentKey, and EnvironmentValues. Create a protocol-based AnalyticsService with a Live, NoOp, and @Observable Mock implementation. Inject at the App scene root and consume with @Environment. Make it accessible (VoiceOver labels on all interactive elements). Add a #Preview with realistic sample data showing the mock.
In Soarias's Build phase, paste this prompt into the active Claude Code session after your data model is scaffolded — Claude will generate the protocol, key, and environment extension as a coherent unit you can drop straight into your project.
Related
FAQ
Does this work on iOS 16?
The core EnvironmentKey / @Environment pattern works on iOS 14+. The only iOS 17-specific piece in this guide is @Observable on MockAnalytics. To target iOS 16, replace @Observable with ObservableObject and mark events with @Published. Everything else compiles unchanged.
Can I inject SwiftData's ModelContext the same way?
You don't need to — SwiftUI already propagates ModelContext automatically when you attach a .modelContainer(…) modifier. Access it in any child view with @Environment(\\.modelContext). For other repositories or data-access objects that wrap SwiftData, the EnvironmentKey pattern shown here is the right approach.
What's the UIKit equivalent?
In UIKit, dependency injection is typically done via initializer injection — pass services into a UIViewController's init, or use a service locator (a global registry like ServiceLocator.shared). SwiftUI's environment is superior: it's hierarchical, thread-safe by design, and trivially overridable in previews and tests without any global mutable state.
Last reviewed: 2026-05-11 by the Soarias team.