Effect-free domain logic in Scala

(, en)

How can and should I integrate my pure domain (or business) logic with effect systems?

We’ll focus here on ZIO, but the line of reasoning is the same in other effect systems. Let’s start with

The Ultimate Sin

Wrapping pure code into an IO

def add(a: Int, b: Int): ZIO[Any, Nothing, Int] = ZIO.succeed(a + b)

This example is taken from the talk The Cost of Your ZIO Addiction by Jules Ivanic

The problem with effect systems, as suggested by Martin Odersky in one of his talks is that you, at least for ZIO, tend to »overprovision«, because everything has to be in a single monad. Your code gets more verbose and probably also slower, because you drag in the complete machinery of the effect system. A picture is worth a thousand words:

ZIO is infectious

Don’t use ZIO

So what can you do instead?

Don’t use ZIO in your pure domain codeAnd place pure domain code in a (companion) object!

And that works surprisingly well. You have Option, Either, Try, Cats’ Validated , etc. I’m not talking about Cats Effect, but about Cats, a library which provides abstractions for functional programming

I also had a look at ZPure from ZIO prelude, but that looks even more complicated than the ZIO monad:

A ZPure[W, S1, S2, R, E, A] is a description of a computation that requires an environment R and an initial state S1 and either fails with an error of type E or succeeds with an updated state of type S2 and a value of type A, in either case also producing a log of type W.

There is no need to use ZPure, but the only thing that seems tricky is logging. How do you log in pure domain logic?

Logging in your domain code

You have basically two approaches:

Direct logging looks like this:

import org.slf4j.LoggerFactory

object PricingServiceA {
   private lazy val logger = LoggerFactory.getLogger(this.getClass)
}

// OR

import com.typesafe.scalalogging.LazyLogging
object PricingServiceB extends LazyLogging

Alternatively, you can also choose for something that fits better with your effect system. In my case, I created a simple logger, called PureLogger, to help me with this. PureLogger can be passed as implicit/given and works very well in for otherwise pure logic functions.

def domainLogic(day: String)(using logger: PureLogger): String = {
  logger.info(s"$day is a nice day")

  "result"
}

// the end of the scope will automatically flush all collected logs to ZIO logging
ZIO.scoped {
  for {
    // you create an implicit/given in your for-comprehension
    given PureLogger <- PureLogger.defaultZIO
    // the domainLogic takes the implicit logger and appends logs, if needed
    result = domainLogic("today")
    _ <- insertIntoDb(result)
  } yield result
}

With calling PureLogger.defaultZIO you get a scoped ZIO object back. This makes it possible to skip an explicit flush() call. When the scope closes, the logs are automatically flushed.

PureLogger should be used in a relatively small scope, it caps the number of messages by default at 40 000, so you won’t get memory issues.

Sometimes opponents of this approach argue

But now you have to pass the PureLogger to all functions. This is annoying! I’ll prefer to use ZIO instead!

Overall I realised that

What about time?

For time I also use an implicit/given. It becomes immediately obvious that time is needed in a function. With ZIO I had it already a couple of times that somewhere in a nested function ZIO.clock is used without me noticing it.

val app = for {
  given java.time.Clock <- jClockZIO
  shipment <- ZIO.fromEither(avroShipment.toDomain())
  _ <- shipmentRepo.insert(shipment)
} yield ()


extension (shipment: avro.Shipment) {
    def toDomain()(using clock: Clock): Either[Throwable, Shipment] = ???
}

def jClockZIO: ZIO[Any, Nothing, time.Clock] = ZIO.clock.flatMap(_.javaClock)

Conclusion

I really like vanilla Scala domain code. With Scala 3 given objects are easier to see and trace in the code, making the usage of implicits a powerful tool in writing pure domain logic.

But as always: don’t overdo it. In my code I try to restrict myself to givens/implicits for:

Once you are that far, you can

place pure domain code in a (companion) object

and make the pure / ZIO boundary even clearer.

The code for PureLogger you can find in my zio-snippets repo