Opening the fridge with ZIO

Wiem Zine
7 min readJun 7, 2020

--

Hello 👋 ,

This blog post is inspired by the tweet of Jakub Kozłowski λ:

I made a coding session to implement a system using ZIO that processes requests to prepare different types of Pizzas using the available ingredients.

The system stores the requests in a queue and has the state for the available ingredients, handleRequests makes sure that there are available ingredients to prepare the requested pizza. You can checkout the coding session here.

class System(requests: Queue[Request],
currentIngredients: Ref[Map[Ingredient, Int]])

In this blog post we’re going to put these ingredients in the Fridge and prepare the pizza 🍕 following these steps:

  1. Open the Fridge.
  2. Use the ingredients and update the content of the fridge.
  3. Close the fridge and save electricity.

Now the system shouldn’t have access to the current ingredients, we can omit the state:

class System(requests: Queue[Request])

The Fridge holds all the available ingredients, we shouldn’t access them only after opening the fridge.

The fridge could be Opened or Closed

sealed trait State
object State {
case object Opened extends State
case object Closed extends State
}

we’re going to persist the available ingredients and the fridge state:

case class FridgeWithState(ingredients: Map[Ingredient, Int], state: State)

This application might fail with:

  • UnavailableIngredients if there is not enough ingredients to make the Pizza.
  • WrongState for example when someone wants to update the ingredients without opening the fridge.

We can define the errors as following:

sealed trait Error object Error {
case class UnavailableIngredient(ingredient: Ingredient) extends Error
case class WrongState(current: State, expected: State) extends Error
}

Now we can define the Fridge as a dependency that will be used by the System:

object fridge {
type Fridge = Has[Service]
trait Service {
protected def currentState: Ref[FridgeWithState]
def open: IO[WrongState, Map[Ingredient, Int]]
def close: UIO[Unit]
def set(newIngredients: Map[Ingredient, Int]): IO[WrongState,
Unit]
def fridgeState: UIO[FridgeWithState]
}
...
}
  • Fridge type holds the service API that will be implemented in a ZLayer
  • currentState is protected and couldn’t be accessed by external apis.
  • open returns the available ingredients in the Fridge and changes the state to Opened .
  • close closes the fridge and changes the state to Closed.
  • set sets new ingredients to the Fridge.
  • open and set don’t provide a result if the Fridge state is wrong in this case the computation fails with WrongState.
  • fridgeState returns the current fridge state with the available ingredients.

ℹ️ We can add more methods to this Api, the current implementation takes the necessary methods that the system could use.

System#handleRequests

Before: the system persists the availableIngredients and handleRequests implementation was:

def handleRequests[R](
fallbackAction: (UnavailableIngredient, String) => URIO[R, Unit]
): URIO[R with Clock, Unit] =
(for {
request <- requests.take
oldState <- currentIngredients.get
newState <- System
.preparePizza(request, oldState)
.catchAll(e => fallbackAction(e, request.name).as(oldState))
_ <- currentIngredients.set(newState)
} yield ())
.repeat(Schedule.spaced(15.seconds) && Schedule.duration(8.hours))
.unit

Now, we’re going to use Error which could be either UnavailableIngredient or WrongState and handleRequests has new dependency Fridge which is of type Has[fridge.Service] (as defined above)

def handleRequests[R](
fallbackAction: (Error, String) => URIO[R, Unit]
): URIO[R with Clock with Fridge, Unit] =
(for {
request <- requests.take
fridge <- ZIO.service[fridge.Service]
_ <- fridge.open.flatMap { oldState =>
System
.preparePizza(request, oldState)
.flatMap(newState => fridge.set(newState))
}
.catchAll(e => fallbackAction(e, request.name))
.ensuring(fridge.close)
} yield ())
.repeat(Schedule.spaced(15.seconds) && Schedule.duration(8.hours))
.unit
  • Using ZIO.service[fridge.Service] we accesses to the Fridge service methods.
  • open the fridge to get the current available ingredients.
  • use the old state to prepare the Pizza.
  • Update the state
  • Make sure that the fridge will be closed in case of : failure, success or interruption.

We can open the fridge using zio in different ways:

  • Using ensuring (like the code above)
fridge.open.flatMap { oldState =>
System
.preparePizza(request, oldState)
.flatMap(newState => fridge.set(newState))
}
.ensuring(fridge.close)
  • Using bracket :
fridge.open.bracket(_ => fridge.close) { oldState =>
System
.preparePizza(request, oldState)
.flatMap(newState => fridge.set(newState))
}
  • Using ZManaged :
val resource = ZManaged.make(fridge.open)(_ => fridge.close)
resource.use { oldState =>
System
.preparePizza(request, oldState)
.flatMap(newState => fridge.set(newState))
}

These ways guarantee resource safety.

⚠️ if open fails because the fridge had a wrong initial state, handleRequests will fail and the next customers will be affected.

we can fix the fridge state by closing the fridge in the finalizer using: ensuring . And if you’re using bracket or ZManaged you can add ensuring at the end because the resource will not be released if they haven’t been created successfully.

In our case, it would be better to use ensuring :

fridge.open.flatMap { oldState =>
System
.preparePizza(request, oldState)
.flatMap(newState => fridge.set(newState))
}
.ensuring(fridge.close)

Fridge Layer:

We’re going to define an implementation for the fridge service, which requires a currentState of type Ref[FridgeWithState] .

We can build this state using Ref.make(initialState) which returns UIO[FridgeWithState] then we take its value and pass it to the implementation.

The creation of the state could be done when we build the layer.

Using ZLayer.fromEffect we can make the state and use it in the serviceImpl as following:

def serviceImpl(initialState: Ref[FridgeWithState]) = new Service {
override def currentState: Ref[FridgeWithState] = initialState

override def
open: IO[WrongState, Map[Ingredient, Int]] = ???

override def close: UIO[Unit] = ???

override def set(newIngredients: Map[Ingredient, Int]): IO[WrongState, Unit] = ???

override def fridgeState: UIO[FridgeWithState] = ???
}
def live(initialState: FridgeWithState): Layer[Nothing, Fridge] =
ZLayer.fromEffect(Ref.make(initialState))
  • open changes the state to Opened only if the fridge is Closed then returns the available ingredients in the currentState. It will fail with WrongState if the fridge is already opened.

To check the state, we can implement this helper function:

def checkState(state: State, expected: State): IO[WrongState, State] =
if (state == expected) IO.succeed(state)
else IO.fail(WrongState(state, expected))

then we use it to implement open :

override def open: IO[WrongState, Map[Ingredient, Int]] =
for {
fridgeWithState <- currentState.get
_ <- checkState(fridgeWithState.state, State.Closed)
result <- currentState.updateAndGet(_.copy(state = State.Opened))
} yield result.ingredients
  • close closes the fridge and display a message, this function will never fail.
override def close: UIO[Unit] =
currentState
.updateAndGet(_.copy(state = State.Closed))
.unit *> UIO(println("The fridge is closed."))

ℹ️ If you want to use zio.console you can make a layer that has a dependency: Console and uses that service as following:

def live(
initialState: FridgeWithState
): ZLayer[Console, Nothing, Fridge] =
ZLayer.fromServiceM(
console =>
Ref.make(initialState)
.map(state =>
...
override def close: UIO[Unit] =
currentState
.updateAndGet(_.copy(state = State.Closed))
.unit *> console.putStrLn(("The fridge is closed."))
...
))

The fridge service implementation can use different apis and this could be described as a dependency of the ZLayer .

Here fromServiceM acquires the Console service which enables us to access putStrLn method.

  • set updates the ingredients in the fridge only if the fridge is Opened
override def set(
newIngredients: Map[Ingredient, Int]
): IO[WrongState, Unit] =
for {
fridgeWithState <- currentState.get
_ <- checkState(fridgeWithState.state, State.Opened)
_ <- currentState.updateAndGet(
_.copy(ingredients = newIngredients)
)
} yield ()
  • fridgeState returns the current state, this enables us to checkout the ingredients.
override def fridgeState: UIO[FridgeWithState] = currentState.get

Test using fridge.live :

We can test the behavior of system using the live environment as following:

testM("handleRequests") {
val ingredients: Map[Ingredient, Int] =
Map(Tuna -> 5, Tomato -> 3, Cheese -> 2)

val expectedResult = Map(Tuna -> 4, Tomato -> 0, Cheese -> 1)
val initialFridgeState =
fridge.FridgeWithState(ingredients, State.Closed)
(for {
system <- System.start
request = Request("customer#1", PizzaType.Tonno)
_ <- system.sendRequest(request)
fridge <- ZIO.service[fridge.Service]
_ <- system.handleRequests((_, _) => UIO.unit).fork
_ <- fridge.fridgeState.repeat(
Schedule
.doUntil[FridgeWithState](_.ingredients ==
expectedResult))

} yield assertCompletes)
).provideSomeLayer(Clock.live ++ fridge.live(initialFridgeState))
}

in this test case, we started the system and sent a request to prepare a pizza Tonno that requires these ingredients:

Map(Tuna -> 1, Tomato -> 3, Cheese -> 1)

The initial state of the fridge is closed with available ingredients:

Map(Tuna -> 5, Tomato -> 3, Cheese -> 2)

the expected result is to take the requested ingredients and put back the rest in the fridge:

Map(Tuna -> 4, Tomato -> 0, Cheese -> 1)

We can fetch the state repeatedly until we get the expected result and make sure that the assertion has been completed. And in this test we’re using the live clock.

If we want to test a failed scenario, we can put a wrong state for the fridge:

testM("handle requests should fail") {
val ingredients: Map[Ingredient, Int] =
Map(Tuna -> 5, Tomato -> 3, Cheese -> 2)
val initialFridgeState =
fridge.FridgeWithState(ingredients, State.Opened)
(for {
system <- System.start
request = Request("customer#2", PizzaType.Tonno)
_ <- system.sendRequest(request)
fridge <- ZIO.service[fridge.Service]
p <- Promise.make[Nothing, Error]
_ <- system.handleRequests((e, _) => p.succeed(e).unit).fork
fridgeState <- fridge.fridgeState.delay(200.millis)
error <- p.await
} yield
assert(fridgeState.state)(equalTo(State.Closed)) &&
assert(fridgeState.ingredients)(equalTo(ingredients)) &&
assert(error)(
equalTo(WrongState(State.Opened, State.Closed))))
).provideSomeLayer(Clock.live ++ fridge.live(initialFridgeState))
}

in this example we started the program with an opened fridge which is an invalid state, and this will fail the request, but the fridge will be closed afterwards and the state of the available ingredients is not changed.

The handleRequests is running in another fiber and it has a fallback action, here ZIO promise will coordinate the action of that fiber and will take the error, which should be of type WrongState .

Now we cannot prepare pizza for the customer only if we use the fridge correctly. 😅

In this example, we used ZLayer and resource management in ZIO to be able to access an API and save resources.

I hope you learned more about ZIO and you enjoyed reading this.

Now it’s time to prepare a real pizza 🍕 👋

--

--