Lightweight Composible Event Sourcing library for Scala.
Heavily inspired by scalaio-2017-esmonad presentation.
object UserService {
// state
case class Address(country: String, zip: String, strLine1: String, strLine2: Option[String] = None,
state: Option[String] = None)
sealed case class UserRecord(id: UUID, email: String, birthdate: Option[LocalDate] = None,
addresses: Vector[Address] = Vector.empty)
// events
sealed trait UserRecordChangeEvent extends Product with Serializable
case class UserCreated(id: UUID, email: String) extends UserRecordChangeEvent
case class EmailUpdated(newEmail: String) extends UserRecordChangeEvent
case class BirthdateUpdated(birthdate: LocalDate) extends UserRecordChangeEvent
case class AddressAdded(newAddress: Address) extends UserRecordChangeEvent
// state machine
implicit val eventHandler: EventHandler[UserRecord, UserRecordChangeEvent] = EventHandler {
case (None, ev: UserCreated) => UserRecord(ev.id, ev.email)
case (Some(s: UserRecord), ev: EmailUpdated) => s.copy(email = ev.newEmail)
case (Some(u: UserRecord), BirthdateUpdated(newDate)) => u.copy(birthdate = Some(newDate))
case (Some(u: UserRecord), AddressAdded(a)) => u.copy(addresses = u.addresses :+ a)
}
// business logic
def createUser(id: UUID, email: String): SourcedCreation[UserRecord, UserCreated, UUID] =
sourceNew[UserRecord](UserCreated(id, email).asRight).map(_ => id)
def updateEmail(email: String): SourcedUpdate[UserRecord, EmailUpdated, Unit] = source {
_: UserRecord => Either.cond(email.contains("@"), EmailUpdated(email), "email is invalid")
}
def changeBirthdate(birthdate: LocalDate): SourcedUpdate[UserRecord, BirthdateUpdated, Unit] = source {
_: UserRecord =>
Either.cond(
birthdate.isBefore(LocalDate.of(2018, 1, 1)),
BirthdateUpdated(birthdate),
"Too young!"
)
}
def addAddress(country: String, zip: String, strLine1: String, strLine2: Option[String] = None,
state: Option[String] = None): SourcedUpdate[UserRecord, AddressAdded, Unit] = sourceOut { _ =>
Either.cond(country.nonEmpty && strLine1.nonEmpty && zip.nonEmpty,
AddressAdded(Address(country, zip, strLine1, strLine2, state)) -> "",
"Invalid address"
)
}
// composing multiple actions into single action
def createUser(email: String, birthDate: LocalDate): SourcedCreation[UserRecord, UserRecordChangeEvent, UUID] = {
// Side effect that produces id is outside of the `source` scope. Thus it remains pure.
// In other words "id" value remain unchanged if source executed more then once (in case of a retry for example).
val id = UUID.randomUUID()
createUser(id, email) andThen { id =>
changeBirthdate(birthDate).map(_ => id)
}
}
def main(args: Array[String]): Unit = {
// example of execution
val smallProgram = createUser("[email protected]", LocalDate.of(1970, 1, 1)) andThen {
addAddress("United States", "10001", "1 Main str", state = Some("NY"))
}
/**
* Prints out:
*
* 0: UserCreated(c6e105bb-0227-4c0c-b106-a0be5ae0f204,[email protected])
* 1: BirthdateUpdated(1970-01-01)
* 2: AddressAdded(Address(United States,10001,1 Main str,None,Some(NY)))
*
* UserRecord(c6e105bb-0227-4c0c-b106-a0be5ae0f204,[email protected],Some(1970-01-01),
* Vector(Address(United States,10001,1 Main str,None,Some(NY))))
*/
smallProgram.run.map { case (events, finalState, out) =>
println(events.zipWithIndex.map(l => s"${l._2}: ${l._1}").mkString("\n"))
println("\n")
println(finalState)
}
}
}