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:
- Open the Fridge.
- Use the ingredients and update the content of the fridge.
- 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 aZLayer
currentState
is protected and couldnât be accessed by external apis.open
returns the available ingredients in the Fridge and changes the state toOpened
.close
closes the fridge and changes the state toClosed
.set
sets new ingredients to the Fridge.open
andset
donât provide a result if the Fridge state is wrong in this case the computation fails withWrongState
.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 theFridge
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 toOpened
only if the fridge isClosed
then returns the available ingredients in thecurrentState
. It will fail withWrongState
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 isOpened
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 đ đ