scalazio

When to use ZIO environment vs constructor arguments


I am looking to understand what are the best practices for using the ZIO environment, i.e. the R type in ZIO[R, E, A].

In this tweet, John De Goes gives 3 simple rules for when to use the environment R.

  1. Methods inside service definitions (traits) should NEVER use the environment
  2. Service implementations (classes) should accept all dependencies in constructor
  3. All other code ('business logic') should use the environment to consume services

The ZIO home page also gives an example that seems to follow this advice.

My confusion is mostly around what does ZIO consider "services" and what is "business logic". In the ZIO home page example linked to above, the "business logic" is something like:

  val app =
    for {
      id <- DocRepo.save(...)
      doc <- DocRepo.get(id)
      _ <- DocRepo.delete(id)
    } yield ()

This is a bit too simple to get the point across for me. This "application" fits completely into a single function that uses the abstract repository. A more realistic case would be that this logic is for example part of the business logic to handle some request, for example DocumentHttpServer#handleFoo method. But in that case is DocumentHttpServer not itself a "service"? If so, shouldn't the DocRepo be passed in as a constructor argument and shouldn't handleFoo use it directly?

For clarity, the way I understand this DocumentHttpServer would be implemented is:

trait DocumentHttpServer {
  def handleFoo(args: FooArgs): Task[Unit] // this is a service so should have R=Any
}

class DocumentHttpServerImpl(repo: DocRepo) extends DocumentHttpServer {
  def handleFoo(args: FooArgs) =   
    for {
      id <- repo.save(...)   // using the constructor argument to match R=Any in trait
      doc <- repo.get(id)
      _ <- repo.delete(id)
    } yield ()
}

In conclusion, am I wrong to assume that "business logic" is described in some "ZIO service" and if so, its interface should not use the environment?

UPDATE 1. For clarity, the question is when SHOULD YOU use the environment outside of the main function and tests.


Solution

  • Tldr:

    1. Methods inside service definitions (traits) should NEVER use the environment
    2. Service implementations (classes) should accept all dependencies in constructor
    3. All other code (which is not written in a service) should use the environment to consume services

    Longer answer

    "Business logic" (in point 3) is a little misleading, since Services can also contain business logic. For example, let's say you have a UserService which requires a HttpClient (fetches some data from a public API) and a UserRepo (store/fetch users in the database).

    Here you probably want some business logic, for example to prevent users to have the same username and handling errors in the UserRepo and the HttpClient. Since UserService is a service, you want to put dependencies in the constructor:

    class UserServiceImpl(userRepo: UserRepo, httpClient: HttpClient) extends UserService {
       def createUser(name: String): Task[Unit] = ???
    }
    

    Ignoring other intermediate services, you probably want a server running until the application is closed, which handles incoming requests. Same story here. Server is a service, so dependencies goes in constructor instead of the environment:

    class ServerImpl(???) extends Server {
       def start(): Task[Unit] = ???
    }
    
    trait Server {
       def start(): Task[Unit]
    // in reality, this would probably be ZIO[Scope, Throwable, Unit], since your Server probably
    // has some finalizer to close down the server properly, but let's leave that out for now
    }
    
    // let's include the companion object with an accessor method
    object Server {
       def start(): ZIO[Server, Throwable, Unit] = ZIO.serviceWith[Server](_.start)
    }
    

    Next, let's put together the application. Since app is not a layer (edit: service), you want to call the accessor method, which has Server in the environment:

    val app =
    for {
      _ <- ZIO.logInfo("Starting server")
      _ <- Server.start()
    } yield ()
    
    def run = app.provide(ServerImpl.layer, ???, UserRepo.layer, HttpClient.layer)
    

    When calling provide you start from the top and add layers one by one until you have met all requirements in the environment.

    As a final note, methods in the companion object is not considered service code and should use the environment. The two typical cases is the method that constructs the ZLayer, which should be a method in the companion object of the class, and the accessor method(s) which should be implemented in the companion object of the trait.

    Note: Accessor methods can be handy for every service, to make writing tests easier, even though methods in services deeper in the dependency tree would never be called with the accessor methods in production code.

    Note 2: Environment is also used for resource safety (Scope), and can also be used for things that is required for each handled request, like User Context or Database Connection (e.g from a database pool). Scope is a first-class feature in ZIO and UserContext/DbConnection are made up examples.

    Updated answer

    Are you saying that even though public Service methods implementing the corresponding trait DO NOT use accessor methods, other classes used by this service might use these accessor methods? Is then the responsibility of each service method to provide all the layers for such business logic classes?

    No, if you are writing a service with dependencies, you should access those dependencies through the constructor. That goes for parents and children in the dependency tree. Consider Foo, which requires Bar:

    class FooImpl(bar: Bar) extends Foo {
       def someMethod(): Task[String] = ???
    }
    
    class BarImpl(baz: Baz) extends Bar {
       def otherMethod: Task[String] = ???
    }
    

    Both Foo and Bar are services, which means they follow the service pattern defined in the ZIO documentation. You should not use access methods to implement any of these services.

    Another update

    Just like your answer, they say when you SHOULD NOT use the environment. The question is when SHOULD you use the environment?

    You generally shouldn't use the environment because most code should be written in a service. So what's all the fuss with the environment if we almost never use it?

    Technically, all your services use the environment, just not in the methods they implement. Consider how you construct a layer from a service using the Foo/Bar example from above:

    object FooImpl {
       def layer: ZLayer[Bar, Nothing, Foo] = ZLayer {
          bar <- ZIO.service[Bar]
       } yield FooImpl(bar)
    }
    

    You can think about this ZLayer as ZIO[Bar, Nothing, Foo]. ZLayer is where you use the environment for your services.

    You cannot put all your code in a service though, because at some point you need to wire your application up to run it, i.e your "main" method or "run" which is what it's called in a ZIO App. This is where you call one or a few accessor methods.

    Think about what your application should when it starts. For a server application, you mainly just want to start your server and maybe write something to a log, as in the example I showed earlier. For a simpler application or during development of a larger application, perhaps you just want to execute some database calls and close the application. Then you need to call your accessor methods to do that.

    The other common exception where you want to use accessor methods is when you test your services. In a test, you would call Foo.someMethod(), which has Foo in the environment, so that you can provide your implementation and assert that the result is what you expect.

    You asked for a clarification on the UserRepo

    Note: I don't know enough to advice you to do this in production. It's an theoretical example of an exception to the rule. Personally I would only do this if the library I was using worked like that.

    class UserRepoImpl() extends UserRepo {
       def create(name: String): ZIO[Connection, Throwable, Unit] = ???
    }
    

    Then we have a ConnectionPool:

    class ConnectionPoolImpl() extends ConnectionPool {
       def connection(): ZIO[Scope, Throwable, Connection] = ???
    }
    

    We can now let the UserService make sure that only one connection is used to handle one request:

    class UserServiceImpl(userRepo: UserRepo, connectionPool: ConnectionPool) extends UserService {
       def createTwoUsers(name1: String, name2: String) = ???
          // get a connection from pool
          // create user 1
          // create user 2
          // provide the connection to the resulting effect
          // wrap everything in ZIO.scoped to show that we're done with the connection
    }