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.
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:
- SwiftUI provides the environment with values (like
httpClient) - Views declare what they need using
@Environmentproperties - 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:
- SwiftUI provides dependencies through its environment (via
@Entryor customEnvironmentKey) - Your view reads those values using
@EnvironmentorEnvReader - You populate an
Envstruct with those values and pass it to EffectView viainitialEnv: - The transducer accesses dependencies through the
Envstruct 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.
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)
}
}
}
}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 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.
| 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.
This is the default pattern. Env is a struct of instance properties (closures) that are captured at runtime.
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
Envfrom 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.
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.
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.
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 }
))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.
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.
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.
// 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 }
}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.
| 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> |
Choose the static pattern when:
- Performance is critical — eliminate existential overhead in hot paths
- Dependencies are truly global — no need to swap implementations at runtime
- Build-time configuration is sufficient — different builds (debug/release) can use different implementations
- You want maximum compiler optimization — static dispatch enables inlining and devirtualization
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 }
}
#endifOr 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
}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
}
}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
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
| 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 | EnvReader → EffectView(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