Effect-free domain logic in Scala
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:

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 environmentRand an initial stateS1and either fails with an error of typeEor succeeds with an updated state of typeS2and a value of typeA, in either case also producing a log of typeW.
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, so no effect system
- effect-system compatible logging
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
PureLoggerto all functions. This is annoying! I’ll prefer to use ZIO instead!
Overall I realised that
- The code gets much cleaner, because instead of very long
ZIO[R, E, A]types and functions you have vanilla Scala. Once you add one ZIO call somewhere in your code, it spreads like a virus and all you function return types become ZIO, too. WithPureLoggeryou only have(using logger: PureLogger)(or(using PureLogger)if you have to pass it on) - You are not tempted to inject a ZIO service via e.g.
ZIO.service[UserRepository]orZIO.clock - It is easy to recognize the boundary between pure domain logic and effectful code
- You can test the code super-easy, unit tests are sufficient, no mocking or test aspects are needed
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:
- clock/time
- logging (when I work with ZIO)
- execution context
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
