zio / zio

ZIO — A type-safe, composable library for async and concurrent programming in Scala
https://zio.dev
Apache License 2.0
4.07k stars 1.28k forks source link

ZIO Core: Tracking side-effects with ZIO Environment #2962

Closed jdegoes closed 1 year ago

jdegoes commented 4 years ago

This issue is more a thought experiment than anything else. But I think it's useful to jot this down somewhere since I did work on this concept last year, at the time when we introduced ZIO Environment, and others have since opened tickets and pull requests (@sideeffffect).

The core idea: ZIO Environment has grown powerful enough that it's possible to easily track side-effects in the type system, without effect polymorphism.

How?

Imagine defining a SideEffect service that is capable of converting side-effects into functional effects:

type SideEffect = Has[SideEffect.Service]

object SideEffect {
  sealed trait Service {
    private[zio] def effect[A](sideEffect: => A): Task[A]
  }
}

Given such a service—which is sealed and cannot be implemented by code outside ZIO—it would then be possible to retrofit all existing (side-effect) constructors to use the service:

def effect[A](sideEffect: => A): ZIO[SideEffect, Throwable, A] = ???

Now any introduction of a side-effect now forces a dependency on the SideEffect service.

Then, in turn, the only method that could provide an implementation of such a service would be the unsafeRun* family of methods available on a Runtime:

def unsafeRunSync[E, A](eff: ZIO[SideEffect, E, A]): Exit[E, A]

This could not really be done before because of the inability to do partial provision on environments of unknown shape, but that's been rectified with Has / ZLayer.

Cheating: The possible ways of "cheating" are identical to and already exist in tagless-final: mainly by creating and passing along new interfaces (which are implemented by SideEffect but called something else), or just accidentally or intentionally embedding side-effects in code that's supposed to be pure.

Pros

Cons

luis3m commented 4 years ago

Perhaps I'm short-sighted, but I can hardly think of an useful non-side-effectful ZIO function. As you mentioned, in practice side-effects are embedded everywhere. For instance, ZIO.succeed is normally accompanied by a side-effectful IO in another branch of the same block of code:

val result: ZIO[SideEffect, E, A] = if(condition) ZIO.succeed(value) else doSomething()

Another concern is type aliases, will UIO continue to require Any as environment or SideEffect instead?

val a: ZIO[SideEffect, Nothing, Unit] = UIO.effectTotal(println("Hello"))
val b: ZIO[Any, Nothing, Unit] = UIO.succeed(())

In my opinion, to add three (maybe more?) new type aliases to solve this problem is a terrible idea.

sideeffffect commented 4 years ago

I think if this will get implemented, it will influence the architecture of ZIO apps in a good way. SideEffect will nudge programmers to capture side effects (via ZIO.effectTotal & co.) only in the lowest layer(s) of the app. I think this is good from architectural point of view, and will also help people who come new to an already existing ZIO app to comprehend the code base.

EDIT: to expand on my point above, this is not about dividing ZIO functions into side-effectful and non-side-effectful, like @luis3m suggests, but about where can capturing side-effects (methods like effectTotal, ...) hide.

luis3m commented 4 years ago

After some time thinking about this, I think I finally understand what @sideeffffect is suggesting. The idea is to track standalone side-effects with this proposed new SideEffect service which will motive people to move these side-effects to services.

Move from:

object App {
  val findPermissions: ZIO[CacheService with SideEffect, DBException, Permissions] =
    for {
      id <- cacheService.getLastId
      user <- ZIO.effect(getUserFromDB(id))
      perms = user.permissions
    } yield perms
}

To write this:

object userRepository {
  type UserRepository = Has[UserRepository.Service]

  object UserRepository {
    trait Service { def findUserById(userId: UserId): IO[DBException, User] }
  }

  def findUserById(userId: UserId): ZIO[UserRepository, DBException, User] =
    ZIO.accessM[UserRepository](_.get.findUserById(userId))
}

object App {
  val findPermissions: ZIO[CacheService with UserRepository, DBException, Permissions] =
    for {
      id <- cacheService.getLastId
      user <- userRepository.findUserById(id)
      perms = user.permissions
    } yield perms
}

If that's what you are suggesting then I also think that will influence the architecture of ZIO apps in a good way. Nonetheless, I'm wondering how would you define an implementation for UserRepository.Service without resorting to ZIO.effect and others? By using any of these the method signature will change to ZIO[CacheService with UserRepository with SideEffect, DBException, Permissions].

Am I correct @sideeffffect?

neko-kai commented 4 years ago

@luis3m No, If you create an instance of CacheService and supply it with a SideEffect instance such that CacheService uses it but doesn't expose it to clients, then there won't be a SideEffect dependency on clients. I've wrote about this pattern a bit in the second section of izumi zio.Has-support docs (err, nothing self-contained to link to). Basically any service with dependencies on the methods:

trait Service[R] {
  def x: URIO[R, X]
}

Can be turned into a service that requires no dependencies, by wrapping each method in .provide:

def supply(myDep: MyDep)(serviceMyDep: Service[MyDep]): Service[Any] = new Service[Any] {
  def x: URIO[Any, X] = serviceMyDep.x.provide(myDep)
}

This allows you turn unprincipled Reader-based apps that spill all their innards at every corner into principled layered apps that allow you to hide lower-level dependencies (such as SideEffect) with high-level services (such as CacheService)

luis3m commented 4 years ago

@neko-kai like this, I suppose:

object userRepository {
  type UserRepository = Has[UserRepository.Service]

  object UserRepository {
    trait Service {
      def findUserById(userId: UserId): IO[DBException, User]
    }

    object Service {
      def live(sideEffect: SideEffect.Service): Service = new Service {
        def findUserById(userId: UserId): IO[DBException, User] =
          sideEffect.effect(getUserFromDB(userId))
      }
    }

    val any: ZLayer[UserRepository, Nothing, UserRepository] =
      ZLayer.requires[UserRepository]

    val live: ZLayer[SideEffect, Nothing, UserRepository] =
      ZLayer.fromService[SideEffect.Service, UserRepository.Service](Service.live(_))
  }

  def findUserById(userId: UserId): ZIO[UserRepository, DBException, User] =
    ZIO.accessM[UserRepository](_.get.findUserById(userId))
}

object App {
  val findPermissions: ZIO[CacheService with UserRepository, DBException, Permissions] =
    for {
      id <- cacheService.getLastId
      user <- userRepository.findUserById(id)
      perms = user.permissions
    } yield perms
}

Right?

sideeffffect commented 4 years ago

@luis3m yep, I think we're on the same page :smile:

sideeffffect commented 3 years ago

Btw here's the PR where I attempted to implement this https://github.com/zio/zio/pull/1564

ivanopagano commented 1 year ago

Was there any follow-up on this idea now that ZIO 2 is out and stable? Considering how the whole Environment handling has been simplified — e.g. no more Has, things like Scope or Blocking — would this proposal be reconsidered?

adamgfraser commented 1 year ago

I don't think this makes sense in ZIO. We have implemented the Unsafe interface as an alternative, more flexible way of controlling the usage of code that is potentially unsafe, we have a variety of constructors such as ZIO.succeed that allow importing side effecting code into ZIO, and if anything we have reduced the need for users to make this distinction by making all effect constructors lazy.

This could certainly be implemented in user land by creating services representing the desired capabilities and using these services instead of ordinary ZIO constructors, but I think the value of this for side effects in general as opposed to specific capabilities such as needing a cache service or needing a scope is limited.