Skip to content

Latest commit

 

History

History
460 lines (342 loc) · 16.6 KB

File metadata and controls

460 lines (342 loc) · 16.6 KB

Using Env for Dependency Injection

The previous article showed how to model state and logic as a pure, testable function. This article shows how to connect that function to real services — a network client, an analytics tracker, a local cache — while keeping the logic layer free of concrete dependencies and preserving easy testability.


Motivation

When a component needs to perform side effects—network requests, database access, analytics tracking—it must call external services. To keep the core logic pure and testable, you need a way to provide these dependencies from outside the component.

The Env pattern solves this by acting as an abstraction layer that connects your component logic to any existing dependency container. It doesn't impose a specific DI framework; instead, it works with whatever mechanism you already use—SwiftUI's environment, constructor injection, service locator, etc.

For example, SwiftUI's environment works like this:

  1. SwiftUI provides the environment with values (like httpClient)
  2. Views declare what they need using @Environment properties
  3. The view body reads the value through the environment property
struct MyView: View {
    @Environment(\.httpClient) var httpClient
    
    var body: some View {
        Button("Load") {
            Task {
                let data = try await httpClient.get("/api/items")
            }
        }
    }
}

The httpClient value comes from SwiftUI's environment, but the view doesn't know—or care—where it was provided from. It simply accesses it through the environment abstraction.

With EffectView, you use the same pattern: Env is how you inject environment values from outside into your transducer. When using SwiftUI, you don't invent a different DI mechanism—you use the existing SwiftUI environment, populate the Env struct from environment values, and pass it to EffectView.

The flow is:

  1. SwiftUI provides dependencies through its environment (via @Entry or custom EnvironmentKey)
  2. Your view reads those values using @Environment or EnvReader
  3. You populate an Env struct with those values and pass it to EffectView via initialEnv:
  4. The transducer accesses dependencies through the Env struct without knowing their source

This way, your transducer logic stays pure and testable, while still being able to access real services from any DI mechanism.

Example: Using Env with EffectView and EnvReader view:

struct MovieSearchView: View {
    @State private var state = MovieSearchState.idle
    
    var body: some View {
        EnvReader(\.movieSearchEnv) { env in
            EffectView(
                of: MovieSearchLogic.self,
                state: $state,
                initialEnv: env
            ) { state, input in
                MovieSearchContent(state: state, send: input)
            }
        }
    }
}

Core Concepts

Env

Env is a struct of closures or static properties that the transducer uses to access services. It's captured once at host initialization and forwarded to every effect.

Env is not a DI framework. It's the abstraction layer that lets you use whatever DI mechanism you choose — SwiftUI's environment, constructor injection, service locator, etc. The transducer only knows about the Env struct; it doesn't care how that struct was populated.

EnvReader

EnvReader is an optional convenience utility that reads a value from the SwiftUI environment and passes it to EffectView via initialEnv:. It's a thin wrapper around @Environment for ergonomics at the EffectView call site, but you can also populate the Env struct manually without using EnvReader.

Architectural Layers

Layer Responsibility Knows about
Feature State, events, transitions, action closure types Own types only
View SwiftUI layout, EffectView wiring, EnvReader Feature types
Glue EnvironmentValues extension, concrete service instances Feature + infrastructure
Infrastructure Network clients, databases, analytics SDKs Own types only

The feature module declares what it needs (closure types). The glue layer decides what provides it (concrete instances). The two never meet directly.


Instance Env: Closures Captured at Runtime

This is the default pattern. Env is a struct of instance properties (closures) that are captured at runtime.

Step 1: Declare the service API in the Feature layer

The service API lives with the feature, not with the infrastructure. You declare it as a struct of closures — no protocol, no generic parameter.

// MovieSearch/MovieSearchActions.swift  (Feature layer)

import Foundation

struct MovieSearchActions: Sendable {
    var search: @Sendable (String) async throws -> [Movie]
    var cancelSearch: @Sendable () -> Void
    var trackQuery: @Sendable (String) -> Void
}

Using a struct of closures instead of a protocol has several advantages:

  • No generics required. The feature module doesn't need a type parameter for its service.
  • Composition is trivial. You can build a combined Env from several action structs.
  • Test doubles are a literal. Replacing a closure value requires a single-line assignment; no mock class needed.
  • The caller decides the contract. The feature defines precisely what it needs — not what the service is capable of.

Step 2: Build the Env struct

Env is the container that EffectView captures and forwards to every task and action:

// MovieSearch/MovieSearchEnv.swift  (Feature layer)

struct MovieSearchEnv: Sendable {
    var actions: MovieSearchActions
}

For features with a small number of dependencies you can inline the closures directly:

struct MovieSearchEnv: Sendable {
    var search: @Sendable (String) async throws -> [Movie]
    var trackQuery: @Sendable (String) -> Void
}

Either layout works. The struct-of-structs pattern scales better when several features share a dependency group.

Step 3: Use Env in the transducer

Inside a task, env is simply the injected value:

static func transduce(_ state: inout State, event: Event) -> Effect {
    switch (state, event) {
    case (_, .searchTapped(let query)):
        state = .loading(query: query)
        return .sequence([
            .cancel("search"),
            .task(id: "search") { input, env -> TaskReturn<Event> in
                env.trackQuery(query)
                do {
                    let movies = try await env.search(query)
                    return .response(.resultsReceived(movies))
                } catch {
                    return .response(.requestFailed(error.localizedDescription))
                }
            }
        ])
    // ...
    }
}

The transducer function itself never imports the network module. It references env.search — a closure — whose concrete implementation is provided from outside.

Step 4: Inject via the SwiftUI environment

SwiftUI's environment is the right place to propagate dependencies: it's already hierarchical, it reaches every view without threading values through every init, and it integrates naturally with EnvReader.

Declare a key using the @Entry macro (iOS 17 / macOS 14+):

// App/Environment+MovieSearch.swift  (Glue layer)

import SwiftUI
import MovieService  // concrete implementation lives here

extension EnvironmentValues {
    @Entry var movieSearchEnv = MovieSearchEnv(
        search: MovieService.live.search,
        trackQuery: Analytics.live.track
    )
}

The concrete MovieService and Analytics types are referenced only here, in the glue layer. The feature module itself has no import of either.

For older deployment targets, write the key manually:

private struct MovieSearchEnvKey: EnvironmentKey {
    static let defaultValue = MovieSearchEnv(
        search: MovieService.live.search,
        trackQuery: Analytics.live.track
    )
}

extension EnvironmentValues {
    var movieSearchEnv: MovieSearchEnv {
        get { self[MovieSearchEnvKey.self] }
        set { self[MovieSearchEnvKey.self] = newValue }
    }
}

To override the value for a subtree — in a preview, test host, or A/B variant — use .environment(\.movieSearchEnv, ...) on any ancestor view:

MovieSearchView()
    .environment(\.movieSearchEnv, MovieSearchEnv(
        search: PreviewData.search,
        trackQuery: { _ in }
    ))

Step 5: Wire up EffectView

EnvReader reads a value from the SwiftUI environment and makes it available as a closure parameter. Use it to bridge the environment into EffectView:

// MovieSearch/MovieSearchView.swift  (View layer)

struct MovieSearchView: View {
    @State private var state = MovieSearchState.idle

    var body: some View {
        EnvReader(\.movieSearchEnv) { env in
            EffectView(
                of: MovieSearchLogic.self,
                state: $state,
                initialEnv: env
            ) { state, input in
                MovieSearchContent(state: state, send: input)
            }
        }
    }
}

EnvReader is a thin wrapper around @Environment; it exists purely for ergonomics at the EffectView call site. The value it captures is passed to initialEnv:, and EffectView takes ownership from there — forwarding it to every .task, .request, and .action for the lifetime of the view.

Note that the transducer type (MovieSearchLogic.self) is passed directly rather than constructing an inline closure. This keeps the view body free of logic and makes the transition function easily findable and independently testable.

Step 6: Testing with no mocking framework

Because Env is a struct of closures, constructing a test double is constructing a value:

@Suite("MovieSearch transitions")
struct MovieSearchTransitionTests {

    // A test env whose search always returns two movies
    static let testEnv = MovieSearchEnv(
        search: { _ in [Movie(title: "A"), Movie(title: "B")] },
        trackQuery: { _ in }
    )

    @Test func searchTappedTransitionsToLoading() {
        var state = MovieSearchState.idle
        let effect = MovieSearchLogic.transduce(&state, event: .searchTapped(query: "inception"))

        #expect(state == .loading(query: "inception"))
        #expect(effect != nil)
    }

    @Test func resultsArrivedTransitionsToLoaded() async throws {
        var state = MovieSearchState.loading(query: "inception")
        let movies = try await testEnv.search("inception")

        _ = MovieSearchLogic.transduce(&state, event: .resultsReceived(movies))

        #expect(state == .loaded(query: "inception", results: movies))
    }
}

The state-transition tests don't involve Env at all — they call transduce directly and check the resulting state. The Env is only needed in the integration tests that exercise a complete event-effect-event cycle, and there it is a plain struct literal with no framework overhead.


Static Env: Properties Resolved at Compile Time

This is an optimization pattern. Env is a struct of static properties that conform to protocols. The compiler resolves these at build time, eliminating existential containers and enabling aggressive optimization.

Example: Static Env pattern

// Define a protocol for the service interface
protocol SearchService {
    associatedtype Output
    static func search(_ query: String) async throws -> [Output]
}

// Static Env with static properties (no existential)
struct MovieSearchEnv: Sendable {
    static var search: some SearchService<Movie> { MovieService.live }
    static var trackQuery: (String) -> Void { Analytics.live.track }
}

Usage in the transducer

static func transduce(_ state: inout State, event: Event) -> Effect {
    switch (state, event) {
    case (_, .searchTapped(let query)):
        state = .loading(query: query)
        return .sequence([
            .cancel("search"),
            .task(id: "search") { input, env -> TaskReturn<Event> in
                env.trackQuery(query)
                do {
                    // Static dispatch: no existential, direct call
                    let movies = try await env.search.search(query)
                    return .response(.resultsReceived(movies))
                } catch {
                    return .response(.requestFailed(error.localizedDescription))
                }
            }
        ])
    // ...
    }
}

Note the extra .search in env.search.search(query) — the first accesses the static property, the second calls the protocol method.

Comparison: Instance vs Static Env

Aspect Instance Env (closures) Static Env (static properties)
Runtime overhead Small (existential container) Zero (direct static dispatch)
Flexibility High (swap at runtime) Low (fixed at compile time)
Testability Easy (swap closures in tests) Requires build configuration or conditional compilation
Use case Most features, runtime swapping needed Core infrastructure, performance-critical paths
Implementation var search: (String) async throws -> [Movie] static var search: some SearchService<Movie>

When to use Static Env

Choose the static pattern when:

  1. Performance is critical — eliminate existential overhead in hot paths
  2. Dependencies are truly global — no need to swap implementations at runtime
  3. Build-time configuration is sufficient — different builds (debug/release) can use different implementations
  4. You want maximum compiler optimization — static dispatch enables inlining and devirtualization

Testing with Static Env

For testing, you can use Swift's build configuration:

#if DEBUG
extension MovieSearchEnv {
    static var search: some SearchService<Movie> { MockMovieService }
}
#else
extension MovieSearchEnv {
    static var search: some SearchService<Movie> { MovieService.live }
}
#endif

Or use a test-specific build setting to swap the implementation:

// In your test target's build settings:
// SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST_ENV

extension MovieSearchEnv {
    #if TEST_ENV
    static var search: some SearchService<Movie> { MockMovieService }
    #else
    static var search: some SearchService<Movie> { MovieService.live }
    #endif
}

Layering with Static Env

The static pattern works best when your infrastructure layer is organized around protocols with associated types or concrete implementations:

// Infrastructure/MovieService.swift
struct MovieService: SearchService {
    static func search(_ query: String) async throws -> [Movie] {
        // Real network call
    }
}

struct MockMovieService: SearchService {
    static func search(_ query: String) async throws -> [Movie] {
        // Test data
    }
}

Trade-offs

Instance Env (closures)

Advantages:

  • Runtime flexibility — swap implementations at any time
  • Easy testing — test doubles are struct literals
  • No protocol conformance required — just closures

Disadvantages:

  • Small runtime overhead — existential containers for closures
  • Closure capture semantics — reference types in Env may mutate

Limitations:

  • Not suitable for performance-critical hot paths

Static Env (static properties)

Advantages:

  • Zero runtime overhead — direct static dispatch
  • Maximum compiler optimization — enables inlining and devirtualization
  • No existential containers

Disadvantages:

  • Compile-time fixed — no runtime swapping
  • Testing requires build configuration
  • More complex setup — requires protocols with static requirements

Limitations:

  • Only suitable when dependencies are truly global
  • Not suitable when you need to swap implementations at runtime

Summary

Concern Where it lives Characteristic
Env definition Feature layer Struct of closures or static properties
Injection point Glue layer EnvironmentValues extension
View wiring View layer EnvReaderEffectView(initialEnv:)
Service access Transducer env.search(query) or env.search.search(query)

Choose Instance Env when:

  • You need runtime flexibility
  • Testing is a priority
  • Performance is not critical

Choose Static Env when:

  • Performance is critical
  • Dependencies are truly global
  • Build-time configuration is acceptable

Both patterns integrate seamlessly with EffectView's Env mechanism. Choose based on your performance requirements and swapping needs.


Previous: Correct by construction | Next: Bridging event-driven and imperative code