CQRS style
Imports
import edomata.core.*
import edomata.syntax.all.* // for convenient extension methods // for convenient extension methods
import cats.implicits.* // to make life easier
Domain layer
EitherNec
If you are not familiar with EitherNec
from cats, it is basically a type alias like the following
type EitherNec[L, R] = Either[NonEmptyChain[L], R]
which obviously shows that it behaves exactly like Either
;
Examples:
val e1 = Right(1)
// e1: Right[Nothing, Int] = Right(1)
val e2 = "Missile Launched!".asRight
// e2: Either[Nothing, String] = Right(Missile Launched!)
val e3 = "No remained missiles to launch!".leftNec
// e3: Either[Type[String], Nothing] = Left(Chain(No remained missiles to launch!))
either is composable
val e4 = e1.map(_ * 2)
// e4: Either[Nothing, Int] = Right(2)
val e5 = e2 >> e1
// e5: Either[Nothing, Int] = Right(1)
You can also use for-comprehension:
val e6 = for {
a <- e4
b <- e5
} yield a + b
// e6: Either[Nothing, Int] = Right(3)
for more examples see here and also here
Modeling
Let's use what we've learned so far to create an overly simplified model of a food delivery system.
Assume we have the following business requirements:
- User must be able to place order
- Line cook gets notified of new orders
- Line cook allocates food to a cook
- User gets notified of order status
- Kitchen can report that food is ready
- Delivery gets notified of ready foods
- Delivery allocates food to a delivery unit
- Delivery can mark an order as delivered
- User can submit a rating of her experience
I'll use scala 3 enums for modeling ADTs, as they are neat and closer to what modeling is all about; but you can usesealed trait
s and normalcase class
es too
Let's start with aggregate root which is the order here:
enum Order {
case Empty
case Placed(food: String, address: String, status: OrderStatus = OrderStatus.New)
case Delivered(rating: Int)
}
and order status
enum OrderStatus {
case New
case Cooking(cook: String)
case WaitingToPickUp
case Delivering(unit: String)
}
we'll continue with modeling rejection scenarios that also came from event storming:
enum Rejection {
case ExistingOrder
case NoSuchOrder
case InvalidRequest // this should be more fine grained in real world applications
}
No we can use EitherNec
to write our domain logic:
import edomata.core.*
import cats.implicits.*
import cats.data.ValidatedNec
enum Order {
case Empty
case Placed(food: String, address: String, status: OrderStatus = OrderStatus.New)
case Delivered(rating: Int)
def place(food: String, address: String) = this match {
case Empty => Placed(food, address).asRight
case _ => Rejection.ExistingOrder.leftNec
}
def markAsCooking(cook: String) = this match {
case st@Placed(_, _, OrderStatus.New) => st.copy(status = OrderStatus.Cooking(cook)).asRight
case _ => Rejection.InvalidRequest.leftNec
}
// other logics from business
}
DomainModel
In order to complete modeling we must also define our aggregate's initial state;
you can assume that it's like None
in Option[T]
, explicitly defining an initial state has the following advantages:
- Model consistency; you are always working with your domain model, not with Option[YourModel]
- It enables to add new default values in the future, where your model evolves, and reduces the number of times when an upcasting or migration is required.
object Order extends CQRSModel[Order, Rejection] {
def initial = Empty
}
Testing domain model
As everything is pure and all the logic is implemented as programs that are values, you can easily test everything:
Order.Empty.place("kebab", "home")
// res0: Either[Type[Rejection], Placed] = Right(Placed(kebab,home,New))
Order.Placed("pizza", "office").markAsCooking("chef")
// res1: Either[Type[Rejection], Placed] = Right(Placed(pizza,office,Cooking(chef)))
Service layer
Domain models are pure state machines, in isolation, in order to create a complete service you need a way to:
- store and load models
- interact with them
- possibly perform some side effects
you can do all that without Edomata, as everything in Edomata is just pure data and you can use it however you like; but here we'll focus on what Edomata has to offer.
Edomata is designed around the idea of event-driven state machines,
and it's not surprising that the tools that it provides for building services are also event-driven state machines!
these state machines are like actors that respond to incoming messages which are domain commands,
may possibly change state through emitting some events as we've seen in domain modeling above,
and possibly emit some other type of events for communication and integration with other services;
while doing so, they can also perform any side effects that are idempotent,
as these machines may be run several times in case of failure. that takes us to the next building block:
Stomaton
A Stomaton
is an state-driven automata that can do the following:
- read or modify the current state
- ask what is requested (e.g. command message)
- perform side effects
- decide (using EitherNec, return a value or reject with one or more rejections)
- notify (emit notifications, integration events, ...)
- output a value
Stomaton
s are composable, so you can assemble them together, change them or treat them like normal data structures.
for creating our first Stomaton
, we need to model 2 more ADTs; commands, and notifications.
enum Command {
case Place(food: String, address: String)
case MarkAsCooking(cook: String)
case MarkAsCooked
case MarkAsDelivering(unit: String)
case MarkAsDelivered
case Rate(score: Int)
}
enum Notification {
case Received(food: String)
case Cooking
case Cooked
case Delivering
case Delivered
}
and we can create our first service:
object OrderService extends Order.Service[Command, Notification] {
import cats.Monad
def apply[F[_] : Monad]: App[F, Unit] = App.router{
case Command.Place(food, address) => for {
ns <- App.modifyS(_.place(food, address))
_ <- App.publish(Notification.Received(food))
} yield ()
case Command.MarkAsCooking(cook: String) => for {
ns <- App.modifyS(_.markAsCooking(cook))
_ <- App.publish(Notification.Cooking)
} yield ()
case _ => ??? // other command handling logic
}
}
That's it! we've just written our first Stomaton
.
Testing an stomaton
As said earlier, everything in Edomata is just a normal value, and you can treat them like normal data.
import java.time.Instant
// as we've written our service definition in a tagless style,
// we are free to provide any type param that satisfies required type-classes
val srv = OrderService[cats.Id] // or any other effect type
val scenario1 = srv.run(
CommandMessage(
id = "cmd id",
time = Instant.MIN,
address = "aggregate id",
payload = Command.Place("taco", "home")
),
Order.Empty // state to run command on
)
and you can assert on response easily
scenario1.result
// res2: Either[Type[Rejection], Tuple2[Order, Unit]] = Right((Placed(taco,home,New),()))
scenario1.notifications
// res3: Chain[Notification] = Chain(Received(taco))
What's next?
So far we've created our program definitions, in order to run them as a real application in production, we need to compile them using a backend; which I'll discuss in the next chapter