Add commands engine
This commit is contained in:
parent
1919e4b72c
commit
91584c18d5
9 changed files with 336 additions and 58 deletions
74
core/src/lu/foyer/EventSourcing.scala
Normal file
74
core/src/lu/foyer/EventSourcing.scala
Normal 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](_, _, _, _))
|
||||
13
core/src/lu/foyer/Repository.scala
Normal file
13
core/src/lu/foyer/Repository.scala
Normal 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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
core/src/lu/foyer/clients/ClientReducer.scala
Normal file
48
core/src/lu/foyer/clients/ClientReducer.scala
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue