notxcain / aecor

Pure functional event sourcing runtime
aecor.io
MIT License
323 stars 34 forks source link

Scala.js support #54

Open notxcain opened 5 years ago

notxcain commented 5 years ago

@SemanticBeeng I think it’s better to move our discussion from the closed PR #50.

I’m very into adding Scala.js support. You don’t need Akka for single-machine environment (assuming we run entities in browser). The runtime is very easy to implement. All components are already here.

We need to decide how the event journal should be implemented, in-mem or using browser local storage. It’s up to you to decide, I’ll help you to implement it, but I don’t have experience with browser specific API

SemanticBeeng commented 5 years ago

Thanks. Yes, can see the excellent modularity. Trying to knit a few things I know of and thinking through a few aspects.

I'd like to

  1. Have the behavior algebra and the free monad machinery runnable in both server and client For example am looking at frees.io how they do this part since they handle scala.js so there is a chance to reuse.

In aecor we have @autoFunctorK and frees.io has @free and @tagless http://frees.io/docs/core/algebras/ These seem to be very similar - again, looking there because of scala.js support and to better understand where the awareness of "journal" comes in.

  1. Have the behavior algebra decoupled from the "journal" - I found this uncommittedEvents design pattern very useful for managing state between client and server as lists of domain events.

Please see https://tech.zilverline.com/2011/02/10/towards-an-immutable-domain-model-monads-part-5 for details.

This pattern is like a "journal" but decoupled from the actual persistence mechanism.

A code example for an idea.

Note ReturnVal(Left(true), events) - this allows state mutation effects from server to be re-played on client thus updating client state and UI.

The pattern works in reverse: domain events from client side effects / state mutations can be re-played on server thus allowing enforcing some business rules that cannot be evaluated in the client.

  override def registerCollateral(customerId: CustomerId, collateral: Collateral)
  : Future[ReturnVal[Boolean]] = {

    val p = Promise[ReturnVal[Boolean]]()

    try {
      val customer = CustomerRepository.findCustomerById(customerId)

      if (!customer.isDefined) {

        p.failure(BusinessException(s"Could not find customer $customerId"))

      }
      else {
        CustomerServiceEventStore.command_RegisterCollateral(customer.get, collateral)

        val events = customer.get.uncommittedEvents
        customer.get.markCommitted()

        p.success(ReturnVal(Left(true), events))
      }
    } catch {
      case NonFatal(e) =>
        Logger.error(CLS + "registerCollateral: " + e)
        p.failure(e)
    }
    p.future
  }

Showing all of this to make the case that this is not so much about scala.js as about an event sourcing flavored programming model that works with "client" as well.

I wonder if you would agree to review this uncommittedEventss simple pattern and consider that there might not be a need for full journal in client.

Is there a way to achieve this same effect of events detached from journal with aecor already somehow?

notxcain commented 5 years ago

Yes, there is almost everything you need.


sealed abstract class CustomerEvent
final case class CollateralRegistered(collateral: Collateral) extends CustomerEvent

sealed abstract class CustomerRejection
object CustomerNotFound extends CustomerRejection

def update(state: Option[CustomerState], e: CustomerEvent): Folded[Option[CustomerState]] = ???

@autoFunctorK(false)
trait Customer[F[_]] {
  def registerCollateral(collateral: Collateral): F[Unit]
}

final class CustomerActions[F[_]](
  implicit 
    F: MonadActionReject[F, Option[CustomerState], CustomerEvent, CustomerRejection]
  ) extends Customer[F] {
  import F._
  def registerCollateral(collateral: Collateral): F[Unit] = 
    read.flatMap {
      case Some(customer) =>
        append(CollateralRegistered(collateral))
      case None =>
        reject(CustomerNotFound)
    }
}

type In[A] = ActionT[EitherT[IO, CustomerRejection, ?], Option[CustomerState], CustomerEvent, ?]
type Out[A] = IO[Either[CustomerRejection, (Chain[CustomerEvent], A))

val customerActions = new CustomerActions[In]

val customers: CustomerId => Customer[Out] = {
  customerId =>
   customerActions.mapK(new (In ~> Out) {
  def apply[A](action: In[A]): Out[A] = 
    CustomerRepository.findCustomerById(customerId)
      .flatMap { customerState =>
        action.run(customerState, _.update(_)).value.flatMap {
          case Right(Next(a @ (events, _))) =>  CustomerRepository.appendEvents(customerId, events).as(Right(a))
          case Left(rejection) =>  IO(Left(rejection))
          case Right(Impossible) => IO.raiseError(new IllegalStateException)
        }
    }
}

I don't know what markCommitted does, but I think it mutates state somehow which I prefer to avoid, so you need some kind of journal anyway to persist events, easy to implement using cats.effect.concurrent.Ref. Also I assumed that findCustomerById and appendEvents are in IO[_].

vpavkin commented 5 years ago

@SemanticBeeng I can only see slight problems with java.time.* classes used in some places, in particular aecor.util.Clock. Most would be covered by https://github.com/scala-js/scala-js-java-time, but ZonedDateTime is not there yet, for example.

So I would first move Clock to schedule module, and then core would surely cross-compile.

SemanticBeeng commented 5 years ago

Excellent example Denis.

Reviewed this thoroughly and looks very good. Not certain yet how CustomerState fits with uncommittedEvents but almost there.

case Right(Next(a @ (events, _))) => CustomerRepository.appendEvents(customerId, events).as(Right(a)) made it clear that persistence is in the hands of the user and not forced by framework.

In this previous project I reused this little pattern from the article above

trait AggregateRoot[Event] {
  protected def applyEvent: Event => Unit

  def uncommittedEvents: Iterable[Event] = _uncommittedEvents.clone()

  def markCommitted() = _uncommittedEvents.clear()

  def loadFromHistory(history: Iterable[Event]) = history.foreach(applyEvent)

  def record(event: Event) {
    applyEvent(event)
    _uncommittedEvents += event
  }

  private val _uncommittedEvents = mutable.Queue[Event]()
}

Wip on fitting this in with your example but can see things to try.

So by the example above you mean that such kind of code should cross-build?