Exit e-book
Show all chapters
04
Implementing the Tic-Tac-Toe application
04. 
Implementing the Tic-Tac-Toe application
Mastering Modularity in ZIO with Zlayer
04

Implementing the Tic-Tac-Toe application

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:

ZIO Tic tac toe app project structure

So, each ZIO service will be implemented as a package containing:

  • Service interface as a trait.
  • Service implementations as case classes.

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)

 

Implementing the GameCommandParser Service

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:

  • Doesn’t have any dependencies.
  • Can’t fail on creation.
  • Returns a GameCommandParser.

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]
}

Implementing the GameMode Service

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:

  • Depends on GameCommandParser, GameView, OpponentAi and GameLogic.
  • Can’t fail on creation.
  • Returns a GameMode.

Creating more powerful ZLayers

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!

Implementing the TicTacToe main object

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:

  • TicTacToe extends ZIOAppDefault
  • The program value defines the logic of our application, and it depends on the RunLoop Service, which in turn depends on the rest of the services of our application.
  • The run method, that must be implemented by every ZIO application, provides a prepared environment for making our program runnable. To do that, it executes program.provideLayer to provide the prepared ZLayer (defined by the environmentLayer value.

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:

  • ConfirmCommandParser and ConfirmView
  • MenuCommandParser and MenuView
  • GameCommandParser, GameView, GameLogic and OpponentAi

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:

 

ZIO OpponentAI layer

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:

ZIO-horizontal-composition

Next, we can apply a horizontal composition again

 val controllerDeps: ULayer[ConfirmMode with GameMode with MenuMode] =
  confirmModeNoDeps ++ gameModeNoDeps ++ menuModeNoDeps

 

 

Zlayer

The next step will be (spoiler alert): Vertical composition!

val controllerNoDeps: ULayer[Controller] = controllerDeps >>> ControllerLive.layer
Vertical

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
scale application

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.

PREVIOUS
Chapter
03
NEXT
Chapter
05