Dependency Injection Made Composable

Episode #91 • Feb 17, 2020 • Subscriber-Only

While we love the “environment” approach to dependency injection, which we introduced many episodes ago, it doesn’t feel quite right in the composable architecture and introduces a few problems in how we manage dependencies. Today we’ll make a small tweak to the architecture in order to solve them!

Dependency Injection Made Composable
Introduction
00:40
Effects recap
02:17
Environment recap
08:31
Current problems
11:19
Environment in the reducer
16:52
Environment in the store
23:34
Erasing the environment from the store
27:44
Till next time
31:58

Unlock This Episode

Our Free plan includes 1 subscriber-only episode of your choice, plus weekly updates from our newsletter.

Introduction

When introducing the composable architecture last year we put a particular emphasis on side effects and testing. In fact, we devoted 8 entire episodes to those topics. This is because every architecture needs to have a story for side effects: it’s just a fact of life. But as soon as you introduce side effects you run the risk of destroying testability.

However, we were able to make side effects and testability live in harmony by employing an old technique that we talked about nearly two years ago: the environment. The environment provides a single place to put all of the things that can cause side effects, and we forbid ourselves from using any dependencies unless it is stored in the environment. This gives us a consistent way of accessing dependencies, and makes it trivial to swap out dependencies for controlled ones in tests, playgrounds, and we can even use mock dependencies when running the actual application if we wanted. This can be particularly useful if you don’t have internet or want to test your app in a very specific state.

However, the solution we gave is not the whole picture. It works well enough in many cases, but it has a few problems, and we can devise a much more robust and universal solution to the problem of dependencies in the composable architecture. In order to see this all we have to do is make a very small tweak to our reducer signature, and everything will naturally follow.

But before we get into that, let’s remind ourselves how we used the environment technique to control and test our side effects.

This episode is for subscribers only.

Subscribe to Point-Free

Access this episode, plus all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Exercises

  1. Fix the PrimeTime application so that it works with the new reducer signature we developed in this episode.

  2. What are the differences between the reducer we defined in the episode:

    (inout Value, Action, Environment) -> [Effect<Action>]
    

    And this alternative formulation, where Environment has been curried to after Value and Action?

    (inout Value, Action) -> (Environment) -> [Effect<Action>]
    

    Update the ComposableArchitecture module to use this form of Reducer. What impact does it have on behavior and ergonomics?

    Solution

    The architecture now looks like this:

    public typealias Reducer<Value, Action, Environment> = (inout Value, Action) -> (Environment) -> [Effect<Action>]
    
    public func combine<Value, Action, Environment>(
      _ reducers: Reducer<Value, Action, Environment>...
    ) -> Reducer<Value, Action, Environment> {
      return { value, action in
        let effects = reducers.map { $0(&value, action) }
        return { environment in effects.flatMap { $0(environment) } }
      }
    }
    
    public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction, LocalEnvironment, GlobalEnvironment>(
      _ reducer: @escaping Reducer<LocalValue, LocalAction, LocalEnvironment>,
      value: WritableKeyPath<GlobalValue, LocalValue>,
      action: CasePath<GlobalAction, LocalAction>,
      environment: @escaping (GlobalEnvironment) -> LocalEnvironment
    ) -> Reducer<GlobalValue, GlobalAction, GlobalEnvironment> {
      return { globalValue, globalAction in
        guard let localAction = action.extract(from: globalAction) else { return { _ in [] } }
        let localEffects = reducer(&globalValue[keyPath: value], localAction)
    
        return { globalEnvironment in
          localEffects(environment(globalEnvironment)).map { localEffect in
            localEffect.map(action.embed)
              .eraseToEffect()
          }
        }
      }
    }
    
    public func logging<Value, Action, Environment>(
      _ reducer: @escaping Reducer<Value, Action, Environment>
    ) -> Reducer<Value, Action, Environment> {
      return { value, action in
        let effects = reducer(&value, action)
        let newValue = value
        return { environment in
          [.fireAndForget {
            print("Action: \(action)")
            print("Value:")
            dump(newValue)
            print("---")
            }] + effects(environment)
        }
      }
    }
    
    public final class Store<Value, Action>: ObservableObject {
      private let reducer: Reducer<Value, Action, Any>
      private let environment: Any
      @Published public private(set) var value: Value
      private var viewCancellable: Cancellable?
      private var effectCancellables: Set<AnyCancellable> = []
    
      public init<Environment>(
        initialValue: Value,
        reducer: @escaping Reducer<Value, Action, Environment>,
        environment: Environment
      ) {
        self.reducer = { value, action in
          let effects = reducer(&value, action)
          return { environment in effects(environment as! Environment) }
        }
        self.value = initialValue
        self.environment = environment
      }
    
      public func send(_ action: Action) {
        let effects = self.reducer(&self.value, action)(self.environment)
        effects.forEach { effect in
          var effectCancellable: AnyCancellable?
          var didComplete = false
          effectCancellable = effect.sink(
            receiveCompletion: { [weak self] _ in
              didComplete = true
              guard let effectCancellable = effectCancellable else { return }
              self?.effectCancellables.remove(effectCancellable)
          },
            receiveValue: self.send
          )
          if !didComplete, let effectCancellable = effectCancellable {
            self.effectCancellables.insert(effectCancellable)
          }
        }
      }
    
      public func view<LocalValue, LocalAction>(
        value toLocalValue: @escaping (Value) -> LocalValue,
        action toGlobalAction: @escaping (LocalAction) -> Action
      ) -> Store<LocalValue, LocalAction> {
        let localStore = Store<LocalValue, LocalAction>(
          initialValue: toLocalValue(self.value),
          reducer: { localValue, localAction in
            self.send(toGlobalAction(localAction))
            localValue = toLocalValue(self.value)
            return { _ in [] }
        },
          environment: self.environment
        )
        localStore.viewCancellable = self.$value.sink { [weak localStore] newValue in
          localStore?.value = toLocalValue(newValue)
        }
        return localStore
      }
    }
    

    By delaying the application of an environment to a reducer, we are given stricter guarantees that it can never be used to execute arbitrary side effects that mutate the reducer’s value. And this is because value has in-out semantics, and a reducer must mutate this value before opening the trailing function that has an environment in scope. Overall this formulation of a reducer is a little stricter, but also a little less ergonomic, due to the increased nesting involved.

  3. Revert any work left over from the previous exercise.

    Update the logging higher-order reducer to make the caller provide their own printing function instead of using print directly in the implementation. The API for this can allow the caller to get access to the printing function by plucking it out of the environment:

    public func logging<Value, Action, Environment>(
      _ reducer: @escaping Reducer<Value, Action, Environment>,
      logger: @escaping (Environment) -> (String) -> Void
    ) -> Reducer<Value, Action, Environment>
    

    Note that the Swift standard library has a second dump function that prints into a given stream/string rather than the console.

    Solution
    public func logging<Value, Action, Environment>(
      _ reducer: @escaping Reducer<Value, Action, Environment>,
      logger: @escaping (Environment) -> (String) -> Void
    ) -> Reducer<Value, Action, Environment> {
      return { value, action, environment in
        let effects = reducer(&value, action, environment)
        let newValue = value
        return [.fireAndForget {
          let print = logger(environment)
          print("Action: \(action)")
          print("Value:")
          var dumpedValue = ""
          dump(newValue, to: &dumpedValue)
          print(dumpedValue)
          print("---")
          }] + effects
      }
    }
    
  4. Erase the environment from the store without resorting to a force cast. The simplest way to achieve this is to remove the Anys:

    • Remove the environment property from the store so that it’s not even possible to force cast it.
    • Replace the reducer property with a Reducer<Value, Action, Void>
    • Update the initializer, send, and view methods accordingly.
    Solution

    The initializer has an environment at the ready, so all we need to do is capture it and pass it along while ignoring the environment inside.

    public final class Store<Value, Action>: ObservableObject {
      private let reducer: Reducer<Value, Action, Void>
      @Published public private(set) var value: Value
      private var viewCancellable: Cancellable?
      private var effectCancellables: Set<AnyCancellable> = []
    
      public init<Environment>(
        initialValue: Value,
        reducer: @escaping Reducer<Value, Action, Environment>,
        environment: Environment
      ) {
        self.reducer = { value, action, _ in
          reducer(&value, action, environment)
        }
        self.value = initialValue
      }
    
      public func send(_ action: Action) {
        let effects = self.reducer(&self.value, action, ())
        effects.forEach { effect in
          var effectCancellable: AnyCancellable?
          var didComplete = false
          effectCancellable = effect.sink(
            receiveCompletion: { [weak self] _ in
              didComplete = true
              guard let effectCancellable = effectCancellable else { return }
              self?.effectCancellables.remove(effectCancellable)
          },
            receiveValue: self.send
          )
          if !didComplete, let effectCancellable = effectCancellable {
            self.effectCancellables.insert(effectCancellable)
          }
        }
      }
    
      public func view<LocalValue, LocalAction>(
        value toLocalValue: @escaping (Value) -> LocalValue,
        action toGlobalAction: @escaping (LocalAction) -> Action
      ) -> Store<LocalValue, LocalAction> {
        let localStore = Store<LocalValue, LocalAction>(
          initialValue: toLocalValue(self.value),
          reducer: { localValue, localAction, _ in
            self.send(toGlobalAction(localAction))
            localValue = toLocalValue(self.value)
            return []
        },
          environment: ()
        )
        localStore.viewCancellable = self.$value.sink { [weak localStore] newValue in
          localStore?.value = toLocalValue(newValue)
        }
        return localStore
      }
    }
    
  5. We’ve seen that a reducer with a Void environment is equivalent to a reducer that takes no environment at all. Use this knowledge to further simplify the store by holding onto a function that looks like a reducer, but just doesn’t have the 3rd enviroment argument.

    Solution

    Instead of holding onto a Reducer, we can hold onto a simpler function signature:

    public final class Store<Value, Action>: ObservableObject {
      private let reducer: (inout Value, Action) -> [Effect<Action>]
    

    This simplifies the initializer where the private field is assigned:

    self.reducer = { value, action in
      reducer(&value, action, environment)
    }
    

    And the send method, where this field is invoked:

    let effects = self.reducer(&self.value, action)
    

    The view method can still take a void environment.

References

Dependency Injection Made Easy

Brandon Williams & Stephen Celis • Monday May 21, 2018

This is the episode that first introduced our Current environment approach to dependency injection.

Today we’re going to control the world! Well, dependencies to the outside world, at least. We’ll define the “dependency injection” problem and show a lightweight solution that can be implemented in your code base with little work and no third party library.

Dependency Injection Made Comfortable

Brandon Williams & Stephen Celis • Monday Jun 4, 2018

Let’s have some fun with the “environment” form of dependency injection we previously explored. We’re going to extract out a few more dependencies, strengthen our mocks, and use our Overture library to make manipulating the environment friendlier.

How to Control the World

Stephen Celis • Monday Sep 24, 2018

Stephen gave a talk on our Environment-based approach to dependency injection at NSSpain 2018. He starts with the basics and slowly builds up to controlling more and more complex dependencies.

Effectful State Management: Synchronous Effects

Brandon Williams & Stephen Celis • Monday Oct 14, 2019

This is the start of our series of episodes on “effectful” state management, in which we explore how to capture the idea of side effects directly in our composable architecture.

Side effects are one of the biggest sources of complexity in any application. It’s time to figure out how to model effects in our architecture. We begin by adding a few new side effects, and then showing how synchronous effects can be handled by altering the signature of our reducers.

Testable State Management: Reducers

Brandon Williams & Stephen Celis • Monday Nov 25, 2019

This is the start of our series of episodes on “testable” state management, in which we explore just how testable the composable architecture is, effects and all!

It’s time to see how our architecture handles the fifth and final problem we identified as being important to solve when building a moderately complex application: testing! Let’s get our feet wet and write some tests for all of the reducers powering our application.