Side-effectful dependency management

beginner

Oftentimes, our dependencies will interact with the external world, thus causing side effects. Networking operations or persistence are some examples of these dependencies. We can deal with these cases using the Reader pattern. Nonetheless, Bow Effects provides a specific type with additional ergonomics: the EnvIO type.

EnvIO<D, E, A> is a type alias over Kleisli, that models a suspended, side-effectful operation, which has a dependency on D, could cause errors of type E, and returns values of type A. Therefore, it combines dependency management, error handling, and suspension of side effects.

Constructing values of EnvIO

We can create an EnvIO using the invoke method, which lets us pass a throwing function.

let envIO: EnvIO<Dependency, Error, String> = EnvIO.invoke { dependency in
    try dependency.doSomething()
}

The provided function will not be executed in the moment of creation; rather, it will be suspended until the moment we provide the dependency and then call the unsafeRun methods in IO.

Modeling dependencies as algebras

A common approach to deal with dependencies is to abstract them in a protocol that contains a set of operations, often known as an algebra. Then, instead of using the concrete implementation as a dependency, we use the protocol, which would allow us to replace the dependency in other contexts, like testing.

For instance, assuming our software needs to communicate with a backend and persist information, we can model these two dependencies as:

struct User {
    let name: String
}

enum NetworkError: Error {
    case usersNotFound
}

protocol NetworkService {
    func fetchUsers<D>() -> EnvIO<D, NetworkError, [User]>
}

enum PersistenceError: Error {
    case failedWriting(users: [User])
}

protocol PersistenceService {
    func save<D>(users: [User]) -> EnvIO<D, PersistenceError, Void>
}

Notice that both fetchUsers and save(users:) declare a generic D type for their dependencies. This is because they will not have additional dependencies to perform their job, but we still need some type for EnvIO. We could use Any to indicate an operation has no dependencies; however, using a generic type lets the compiler infer the dependency type we are using in the call site.

Combining dependencies

Consider now that we have to implement a workflow where we fetch the users, and then persist and return them. We will need to combine both dependencies into an environment that contains them:

struct Environment {
    let network: NetworkService
    let persistence: PersistenceService
}

In order to combine EnvIO operations, they need to use the same dependencies and error types. We can create a superset of the error type:

enum EnvironmentError: Error {
    case network(NetworkError)
    case persistence(PersistenceError)
}

Then, we can write the workflow:

func workflowAsk() -> EnvIO<Environment, EnvironmentError, [User]> {
    let environment = EnvIO<Environment, EnvironmentError, Environment>.var()
    let users = EnvIO<Environment, EnvironmentError, [User]>.var()
    
    return binding(
        environment <- .ask(),
        users <- environment.get.network.fetchUsers()
            .mapError(EnvironmentError.network),
        |<-environment.get.persistence.save(users: users.get)
            .mapError(EnvironmentError.persistence),
        yield: users.get)^
}

We can use the ask function to get access to the current environment, and then access its properties to invoke the dependencies. Notice that, after the invocation, we need to map the error into the more global error.

There is an alternative way to do it:

func workflowAccess() -> EnvIO<Environment, EnvironmentError, [User]> {
    func fetchUsers() -> EnvIO<Environment, EnvironmentError, [User]> {
        EnvIO.accessM { environment in
            environment.network.fetchUsers()
        }.mapError(EnvironmentError.network)
    }
    
    func persist(users: [User]) -> EnvIO<Environment, EnvironmentError, Void> {
        EnvIO.accessM { environment in
            environment.persistence.save(users: users)
        }.mapError(EnvironmentError.persistence)
    }
    
    return fetchUsers().flatTap { users in
        persist(users: users)
    }^
}

In this version, we use the accessM function, which lets us access the current environment and return an EnvIO that continues operating in the same context. We have extracted the operations in auxiliary functions that adapt the dependency and error types. Finally, instead of using Monad Comprehensions, we can use flatMap / flatTap to sequence the operations.

Towards a global enviroment

Our workflow function works on Environment, but as we move towards other layers of our application, this environment will be more global, containing other dependencies. In order to compose the workflow with other operations, we have mentioned that it must have the same dependency and error type. We have seen we can adapt the error type with mapError; what about the dependency type?

Let’s assume the enviroment type we have in the next layer of our software is GlobalEnvironment:

struct GlobalEnvironment {
    let localEnviroment: Environment
    // + other dependencies
}

We can use contramap in order to generalize the dependency type of an EnvIO. contramap is similar to map, but the function we apply is reversed. That is, if we have EnvIO<D1, E, A> and we need EnvIO<D2, E, A>, we need a function (D2) -> D1 for contramap.

In this particular case, we can use:

let globalWorkflow1: EnvIO<GlobalEnvironment, EnvironmentError, [User]> =
    workflowAccess().contramap { globalEnvironment in
        globalEnvironment.localEnviroment
    }

Or we can use a KeyPath to access the specific dependency we need:

let globalWorkflow2: EnvIO<GlobalEnvironment, EnvironmentError, [User]> =
    workflowAccess().contramap(\.localEnviroment)

The Cake Pattern

The approach above works nicely but has some drawbacks:

  • It imposes a rigid structure for grouping the dependencies, making it hard to replace it, especially when we have workflows that use different subsets of the dependencies contained in the environment.
  • It may expose all dependencies to a given workflow, where we may want to restrict the visibility of some of them.

There is an alternative that is usually known as the Cake Pattern. In this case, we can model capabilities as additional protocols:

protocol HasNetwork {
    var network: NetworkService { get }
}

protocol HasPersistence {
    var persistence: PersistenceService { get }
}

Thus, we can rewrite our workflow as:

func workflowCake<D: HasNetwork & HasPersistence>() -> EnvIO<D, EnvironmentError, [User]> {
    
    let environment = EnvIO<D, EnvironmentError, D>.var()
    let users = EnvIO<D, EnvironmentError, [User]>.var()
    
    return binding(
        environment <- .ask(),
        users <- environment.get.network.fetchUsers()
            .mapError(EnvironmentError.network),
        |<-environment.get.persistence.save(users: users.get)
            .mapError(EnvironmentError.persistence),
        yield: users.get)^
}

With this change, we can provide an environment with more dependencies, but the workflow has limited visibility of what it can use. In order to avoid long lists of protocols, we can group them using type aliases:

typealias WorkflowDependencies = HasNetwork & HasPersistence

Conclusion

Both approaches have benefits and drawbacks in dealing with dependencies. You can use them in combination with partial application or constructor-based dependency injection in order to address the specific problems you have in your case.