CQRS style

Imports

import edomata.core.*
import edomata.syntax.all.* // 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:

I'll use scala 3 enums for modeling ADTs, as they are neat and closer to what modeling is all about; but you can use sealed traits and normal case classes 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:

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:

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:

Stomatons 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