It’s time to implement the Tic-Tac-Toe application using the ZIO Module Pattern with ZLayer! In the following sections, we are going to be analyzing the source code of some of the services (the most representative ones). You can see the complete source code in the jorge-vasquez-2301/zio-zlayer-tictactoe repository.
By the way, this will be the directory structure of the project:
So, each ZIO service will be implemented as a package containing:
Speaking of which, these services reflect the initial design presented above. We also have a domain package containing domain objects and the TicTacToe main object.
We also need to add some dependencies to our build.sbt (atto is used for parsing commands):
val scalaVer = "2.13.6"
val attoVersion = "0.7.2"
val zioVersion = "1.0.10"
lazy val compileDependencies = Seq(
"dev.zio" %% "zio" % zioVersion,
"org.tpolecat" %% "atto-core" % attoVersion
) map (_ % Compile)
lazy val testDependencies = Seq(
"dev.zio" %% "zio-test" % zioVersion,
"dev.zio" %% "zio-test-sbt" % zioVersion
) map (_ % Test)
lazy val settings = Seq(
name := "zio-zlayer-tictactoe",
version := "3.0.0",
scalaVersion := scalaVer,
scalacOptions += "-Ymacro-annotations",
libraryDependencies ++= compileDependencies ++ testDependencies,
testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework"))
)
lazy val root = (project in file("."))
.settings(settings)
Please notice that we are working with Scala 2.13.10 and that we will need to enable the -Ymacro-annotations compiler flag so we will be able to use some of the macros provided by zio-macros. If you want to work with Scala < 2.13, you’ll need to add the macro paradise compiler plugin:
compilerPlugin(("org.scalamacros" % "paradise" % "2.1.1") cross CrossVersion.full)
Here we have the service interface of the GameCommandParser service, in the parser/game/GameCommandParser.scala file:
trait GameCommandParser {
def parse(input: String): IO[AppError, GameCommand]
}
As you can see, the service interface is just a simple trait, which exposes some capabilities, such as the parse method that could fail with an AppError or succeed with a GameCommand. Something very important I want to mention here is that, in general, when writing a service interface you should never have methods that return ZIO effects which require an environment. The reasons for this are very well explained in this article about The Three Laws of ZIO Environment from the ZIO documentation.
Now that we have written the service interface, we need to define the possible implementations. For now, we’ll have just a single implementation, which will be a case class named GameCommandParserLive, in the parser/game/GameCommandParserLive.scala file:
final case class GameCommandParserLive() extends GameCommandParser {
def parse(input: String): IO[AppError, GameCommand] =
ZIO.from(command.parse(input).done.option).orElseFail(ParseError)
private lazy val command: Parser[GameCommand] =
choice(menu, put)
private lazy val menu: Parser[GameCommand] =
(string("menu") <~ endOfInput) >| GameCommand.Menu
private lazy val put: Parser[GameCommand] =
(int <~ endOfInput).flatMap { value =>
Field
.make(value)
.fold(err[GameCommand](s"Invalid field value: $value")) { field =>
ok(field).map(GameCommand.Put)
}
}
}
As demonstrated, the way to write a service implementation is exactly the same as if we were doing Object-Oriented Programming (OOP)! Just create a new case class which implements the service definition (in this case, the GameCommandParser trait). And, because GameCommandParserLive does not have dependencies on any other services, it has an empty constructor.
Next, we need to create a ZLayer that describes how to construct a GameCommandParserLive instance. To do that, just add the following to the GameCommandParserLive companion object:
object GameCommandParserLive {
val layer: ULayer[GameCommandParser] = ZLayer.succeed(GameCommandParserLive())
}
You can appreciate now how easy it is to create a ZLayer! We just need to call the ZLayer.succeed method, providing an instance of GameCommandParserLive. In this case, what’s returned is a ZLayer[Any, Nothing, GameCommandParser], which is the same as ULayer[GameCommandParser]. This means the returned ZLayer:
We are almost done with our GameCommandParser service, we only need to add some accessors, which are methods that help us to build programs without bothering about the implementation details of the service. We put these accessors in the GameCommandParser companion object:
object GameCommandParser {
def parse(input: String): ZIO[GameCommandParser, AppError, GameCommand] =
ZIO.serviceWithZIO[GameCommandParser](_.parse(input))
}
The GameCommandParser.parse accessor uses ZIO.serviceWithZIO to create an effect that requires GameCommandParser as environment and just calls the parse method on it. In general, writing accessors will always follow this same pattern.
Now, the good news here is that actually, we don’t need to write these accessors by ourselves, we can use the @accessible annotation instead (which comes from the zio-macros library) on the GameCommandParser trait. By doing this, accessors will be automatically generated for us:
import zio.macros._
@accessible
trait GameCommandParser {
def parse(input: String): IO[AppError, GameCommand]
}
Here we have the service interface of the GameMode Service in mode/game/GameMode.scala:
@accessible
trait GameMode {
def process(input: String, state: State.Game): UIO[State]
def render(state: State.Game): UIO[String]
}
And, we have the Service Implementation in mode/game/GameModeLive.scala:
final case class GameModeLive(
gameCommandParser: GameCommandParser,
gameView: GameView,
opponentAi: OpponentAi,
gameLogic: GameLogic
) extends GameMode {
def process(input: String, state: State.Game): UIO[State] =
if (state.result != GameResult.Ongoing) ZIO.succeed(State.Menu(None, MenuFooterMessage.Empty))
else if (isAiTurn(state))
opponentAi
.randomMove(state.board)
.flatMap(takeField(_, state))
else
gameCommandParser
.parse(input)
.flatMap {
case GameCommand.Menu => ZIO.succeed(State.Menu(Some(state), MenuFooterMessage.Empty))
case GameCommand.Put(field) => takeField(field, state)
}
.orElseSucceed(state.copy(footerMessage = GameFooterMessage.InvalidCommand))
private def isAiTurn(state: State.Game): Boolean =
(state.turn == Piece.Cross && state.cross == Player.Ai) ||
(state.turn == Piece.Nought && state.nought == Player.Ai)
private def takeField(field: Field, state: State.Game): UIO[State] =
(for {
updatedBoard <- gameLogic.putPiece(state.board, field, state.turn)
updatedResult <- gameLogic.gameResult(updatedBoard)
updatedTurn <- gameLogic.nextTurn(state.turn)
} yield state.copy(
board = updatedBoard,
result = updatedResult,
turn = updatedTurn,
footerMessage = GameFooterMessage.Empty
)).orElseSucceed(state.copy(footerMessage = GameFooterMessage.FieldOccupied))
def render(state: State.Game): UIO[String] = {
val player = if (state.turn == Piece.Cross) state.cross else state.nought
for {
header <- gameView.header(state.result, state.turn, player)
content <- gameView.content(state.board, state.result)
footer <- gameView.footer(state.footerMessage)
} yield List(header, content, footer).mkString("\n\n")
}
}
Notice how the way of defining this GameMode Service is very similar to the definition of GameCommandParser. However, there is a difference: GameCommandParserLive didn’t have any dependencies provided through the class constructor, but GameModeLive has four dependencies! Please notice we are using interfaces for requiring these dependencies, not implementations, so we are following a very important principle from OOP: Program to interfaces, not implementations!
So OK, how do we now create a ZLayer for GameModeLive? We can do it like this:
object GameModeLive {
val layer: URLayer[
GameCommandParser with GameView with OpponentAi with GameLogic,
GameMode
] = ZLayer.fromFunction(GameModeLive(_, _, _, _))
}
We just need to call the ZLayer.fromFunction method on the GameModeLive constructor to lift it to a ZLayer. In this case, what’s returned is a ZLayer[GameCommandParser with GameView with OpponentAi with GameLogic, Nothing, GameCommandParser], which is the same as URLayer[GameCommandParser with GameView with OpponentAi with GameLogic, GameCommandParser]. This means the returned ZLayer:
In this section I want to mention that a more powerful method of building ZLayers is by calling ZLayer.fromZIO (equivalent to ZLayer.apply), that allows us to describe more complex processes for building Services. Just as an example, let’s say we want to print a message to the console when instantiating a GameModeLive, we could do that like this:
object GameModeLive {
val layer: URLayer[
GameCommandParser with GameView with OpponentAi with GameLogic,
GameMode
] =
ZLayer {
for {
gameCommandParser <- ZIO.service[GameCommandParser]
gameView <- ZIO.service[GameView]
opponentAi <- ZIO.service[OpponentAi]
gameLogic <- ZIO.service[GameLogic]
_ <- Console.printLine("Instantiating GameModeLive").orDie
} yield GameModeLive(gameCommandParser, gameView, opponentAi, gameLogic)
}
}
And we can create ZLayers that do even more powerful things such as opening resources (like files), by calling ZLayer.scoped, which allows us to create a ZLayer from a Scoped ZIO effect (I won’t explain all the details about Scoped ZIO effects here, let’s just say that they are the replacement of the old ZManaged data type from ZIO 1.0, so they basically allow to safely allocate and deallocate resources. If you want more details, you can take a look at the ZIO documentation).
Let’s say now, just as an example, that when instantiating GameModeLive the message we print to the console has to come from a file, instead of being hardcoded. We can do this as follows:
val layer: URLayer[
GameCommandParser with GameView with OpponentAi with GameLogic,
GameMode
] = {
import scala.io.Source
val getSource: URIO[Scope, BufferedSource] =
ZIO.acquireRelease(ZIO.attemptBlockingIO(Source.fromFile("message.txt")).orDie)(source =>
ZIO.attemptBlockingIO(source.close).orDie
)
ZLayer.scoped {
for {
gameCommandParser <- ZIO.service[GameCommandParser]
gameView <- ZIO.service[GameView]
opponentAi <- ZIO.service[OpponentAi]
gameLogic <- ZIO.service[GameLogic]
source <- getSource
_ <- Console.printLine(source.mkString("\n")).orDie
} yield GameModeLive(gameCommandParser, gameView, opponentAi, gameLogic)
}
}
So now we have a very powerful ZLayer that doesn’t just know how to instantiate a GameModeLive, but it also knows how to safely deallocate resources (in this case, the message.txt file that gets read) when destroying the instance!
The TicTacToe object is the entry point of our application:
object TicTacToe extends ZIOAppDefault {
val program: URIO[RunLoop, Unit] = {
def loop(state: State): URIO[RunLoop, Unit] =
RunLoop
.step(state)
.some
.flatMap(loop)
.ignore
loop(State.initial)
}
val run = program.provideLayer(environmentLayer)
private lazy val environmentLayer: ULayer[RunLoop] = {
val confirmModeDeps: ULayer[ConfirmCommandParser with ConfirmView] =
ConfirmCommandParserLive.layer ++ ConfirmViewLive.layer
val menuModeDeps: ULayer[MenuCommandParser with MenuView] =
MenuCommandParserLive.layer ++ MenuViewLive.layer
val gameModeDeps: ULayer[GameCommandParser with GameView with GameLogic with OpponentAi] =
GameCommandParserLive.layer ++ GameViewLive.layer ++ GameLogicLive.layer ++ OpponentAiLive.layer
val confirmModeNoDeps: ULayer[ConfirmMode] = confirmModeDeps >>> ConfirmModeLive.layer
val menuModeNoDeps: ULayer[MenuMode] = menuModeDeps >>> MenuModeLive.layer
val gameModeNoDeps: ULayer[GameMode] = gameModeDeps >>> GameModeLive.layer
val controllerDeps: ULayer[ConfirmMode with GameMode with MenuMode] =
confirmModeNoDeps ++ gameModeNoDeps ++ menuModeNoDeps
val controllerNoDeps: ULayer[Controller] = controllerDeps >>> ControllerLive.layer
val runLoopNoDeps = (controllerNoDeps ++ TerminalLive.layer) >>> RunLoopLive.layer
runLoopNoDeps
}
}
Some important points to notice in the code above:
So let’s now analyze step by step the prepareEnvironment implementation. To do that, let’s take another look at our initial design diagram:
The final goal is to provide a RunLoop layer implementation to our TicTacToe.run function. For that, we’ll follow a bottom-up approach.
If we look at the bottom of the updated diagram, we can see there are some opportunities for doing horizontal composition:
So, we have the following in code:
val confirmModeDeps: ULayer[ConfirmCommandParser with ConfirmView] =
ConfirmCommandParserLive.layer ++ ConfirmViewLive.layer
val menuModeDeps: ULayer[MenuCommandParser with MenuView] =
MenuCommandParserLive.layer ++ MenuViewLive.layer
val gameModeDeps: ULayer[GameCommandParser with GameView with GameLogic with OpponentAi] =
GameCommandParserLive.layer ++ GameViewLive.layer ++ GameLogicLive.layer ++ OpponentAiLive.layer
And graphically:
Nice! We can now collapse one more level applying a vertical composition:
val confirmModeNoDeps: ULayer[ConfirmMode] = confirmModeDeps >>> ConfirmModeLive.layer
val menuModeNoDeps: ULayer[MenuMode] = menuModeDeps >>> MenuModeLive.layer
val gameModeNoDeps: ULayer[GameMode] = gameModeDeps >>> GameModeLive.layer
And now we have:
Next, we can apply a horizontal composition again
val controllerDeps: ULayer[ConfirmMode with GameMode with MenuMode] =
confirmModeNoDeps ++ gameModeNoDeps ++ menuModeNoDeps
The next step will be (spoiler alert): Vertical composition!
val controllerNoDeps: ULayer[Controller] = controllerDeps >>> ControllerLive.layer
And finally, we can apply horizontal and vertical composition in just one step, and we’ll be done:
val runLoopNoDeps = (controllerNoDeps ++ TerminalLive.layer) >>> RunLoopLive.layer
That’s it! We now have a prepared environment that we can provide to our program to make it runnable, by calling ZIO#provideLayer:
val run = program.provideLayer(environmentLayer)
Now, just as a mental exercise and to better understand the relationship between the ZEnvironment and ZLayer data types, let’s see how to provide a ZLayer to a ZIO effect, but using ZIO#provideEnvironment instead:
val run =
for {
zEnvironment <- environmentLayer.build
_ <- program.provideEnvironment(zEnvironment)
} yield ()
Here you can see, in essence, what happens under the hood when you call ZIO#provideLayer: The given environmentLayer, which is just a pure description of how to construct the dependencies of our application, gets built by calling ZLayer#build, this returns a ZIO effect which succeeds with a ZEnvironment, which in turn can be provided to our program by calling ZIO#provideEnvironment.