Add commands engine

This commit is contained in:
Paul-Henri Froidmont 2025-02-28 05:40:32 +01:00
parent 1919e4b72c
commit 91584c18d5
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
9 changed files with 336 additions and 58 deletions

View file

@ -0,0 +1,74 @@
package lu.foyer
import zio.*
import java.util.UUID
final case class Entity[T](entityId: UUID, data: T, version: Long)
final case class Event[T](entityId: UUID, data: T, eventId: UUID)
trait StateRepository[State] extends Repository[Entity[State], UUID]
trait EventRepository[State] extends Repository[Event[State], UUID]
trait Reducer[Event, State]:
def reduce(event: Event): Option[State]
def reduce(state: State, event: Event): Option[State]
trait CommandHandler[Command, Event, State]:
def name: String
trait CommandHandlerCreate[Command, Event, State] extends CommandHandler[Command, Event, State]:
def onCommand(entityId: UUID, command: Command): Task[Event]
trait CommandHandlerUpdate[Command, Event, State] extends CommandHandler[Command, Event, State]:
def onCommand(entityId: UUID, state: State, command: Command): Task[Event]
class CommandEngine[Command, Event, State](
val handlers: List[CommandHandler[Command, Event, State]],
reducer: Reducer[Event, State],
val eventRepo: EventRepository[Event],
val stateRepo: StateRepository[State]):
def handleCommand(command: Command, name: String, entityId: UUID): Task[(Event, State)] =
for
handler <- ZIO
.succeed(handlers.find(_.name == name))
.someOrFail(new IllegalArgumentException(s"No handler found for command $name"))
entityOption <- stateRepo.fetchOne(entityId)
(event, newStateOption) <- transition(command, name, entityId, entityOption, handler)
newState <-
ZIO
.succeed(newStateOption)
.someOrFail(new IllegalArgumentException("Reducer cannot resolve state transition"))
newEntity <-
Random.nextUUID.map(Entity(_, newState, entityOption.map(_.version).getOrElse(1)))
_ <- if entityOption.isEmpty then stateRepo.insert(newEntity)
else stateRepo.update(newEntity.entityId, newEntity)
eventEntity <- Random.nextUUID.map(Event(newEntity.entityId, event, _))
_ <- eventRepo.insert(eventEntity)
yield (event, newState)
private def transition(
command: Command,
name: String,
entityId: UUID,
entityOption: Option[Entity[State]],
handler: CommandHandler[Command, Event, State]
): Task[(Event, Option[State])] = (entityOption, handler) match
case (None, h: CommandHandlerCreate[Command, Event, State]) =>
h.onCommand(entityId, command)
.map(event => (event, reducer.reduce(event)))
case (Some(entity), h: CommandHandlerUpdate[Command, Event, State]) =>
h.onCommand(entityId, entity.data, command)
.map(event => (event, reducer.reduce(entity.data, event)))
case (Some(_), _: CommandHandlerCreate[Command, Event, State]) =>
ZIO.fail(
new IllegalArgumentException(s"State already exists when applying create command $name")
)
case (None, _: CommandHandlerUpdate[Command, Event, State]) =>
ZIO.fail(new IllegalArgumentException(s"No state found to apply the update command $name"))
case _ => ZIO.fail(new IllegalArgumentException("Impossible state"))
end CommandEngine
object CommandEngine:
def layer[Command, Event, State](using Tag[Command], Tag[Event], Tag[State]) =
ZLayer.fromFunction(CommandEngine[Command, Event, State](_, _, _, _))

View file

@ -0,0 +1,13 @@
package lu.foyer
import zio.*
import java.util.UUID
final case class Page(number: Int, size: Int, totals: Boolean)
final case class Paged[T](items: List[T], totals: Option[Long])
trait Repository[Entity, Id]:
def fetchOne(id: Id): Task[Option[Entity]]
def fetchMany(page: Page): Task[Paged[Entity]]
def insert(entity: Entity): Task[Unit]
def update(id: Id, entity: Entity): Task[Unit]

View file

@ -9,6 +9,7 @@ enum ClientCommand derives Schema:
case Create(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
@ -16,8 +17,14 @@ enum ClientCommand derives Schema:
case Update(
lastName: Option[ClientLastName],
firstName: Option[ClientFirstName],
birthDate: Option[ClientBirthDate],
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Disable(reason: ClientDisabledReason)
object ClientCommand:
given Schema[ClientCommand.Create] = DeriveSchema.gen
given Schema[ClientCommand.Update] = DeriveSchema.gen
given Schema[ClientCommand.Disable] = DeriveSchema.gen

View file

@ -4,7 +4,9 @@ package clients
import zio.schema.*
import java.time.LocalDate
import zio.schema.annotation.discriminatorName
@discriminatorName("eventType")
enum ClientEvent derives Schema:
case Created(
lastName: ClientLastName,
@ -22,3 +24,9 @@ enum ClientEvent derives Schema:
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Disabled(reason: ClientDisabledReason)
object ClientEvent:
given Schema[ClientEvent.Created] = DeriveSchema.gen
given Schema[ClientEvent.Updated] = DeriveSchema.gen
given Schema[ClientEvent.Disabled] = DeriveSchema.gen

View file

@ -0,0 +1,48 @@
package lu.foyer
package clients
object ClientReducer extends Reducer[ClientEvent, ClientState]:
def reduce(event: ClientEvent): Option[ClientState] = event match
case e: ClientEvent.Created =>
Some(
ClientState.Actif(
e.lastName,
e.firstName,
e.birthDate,
e.drivingLicenseDate,
e.phoneNumber,
e.email,
e.address
)
)
case _: ClientEvent.Updated => None
case _: ClientEvent.Disabled => None
def reduce(state: ClientState, event: ClientEvent): Option[ClientState] = (state, event) match
case (s: ClientState.Actif, e: ClientEvent.Updated) =>
Some(
ClientState.Actif(
e.lastName.getOrElse(s.lastName),
e.firstName.getOrElse(s.firstName),
e.birthDate.getOrElse(s.birthDate),
e.drivingLicenseDate.orElse(s.drivingLicenseDate),
e.phoneNumber.orElse(s.phoneNumber),
e.email.orElse(s.email),
e.address.orElse(s.address)
)
)
case (s: ClientState.Actif, _: ClientEvent.Disabled) =>
Some(
ClientState.Inactif(
s.lastName,
s.firstName,
s.birthDate,
s.drivingLicenseDate,
s.phoneNumber,
s.email,
s.address
)
)
case _ => None
end ClientReducer

View file

@ -2,9 +2,11 @@ package lu.foyer
package clients
import zio.schema.*
import zio.schema.annotation.discriminatorName
import java.time.LocalDate
@discriminatorName("statusType")
enum ClientState derives Schema:
case Actif(
lastName: ClientLastName,