2025-02-28 05:40:32 +01:00
|
|
|
package lu.foyer
|
|
|
|
|
|
|
|
|
|
import zio.*
|
2025-03-03 00:24:13 +01:00
|
|
|
import zio.schema.Schema
|
2025-02-28 05:40:32 +01:00
|
|
|
|
2025-10-06 18:30:22 +02:00
|
|
|
import java.util.UUID
|
|
|
|
|
|
2025-02-28 05:40:32 +01:00
|
|
|
final case class Entity[T](entityId: UUID, data: T, version: Long)
|
|
|
|
|
final case class Event[T](entityId: UUID, data: T, eventId: UUID)
|
|
|
|
|
|
2025-03-03 00:24:13 +01:00
|
|
|
trait StateRepository[Data] extends Repository[Entity[Data], UUID]
|
|
|
|
|
trait EventRepository[Data] extends Repository[Event[Data], UUID]:
|
|
|
|
|
def fetchOne(entityId: UUID, eventId: UUID): Task[Option[Event[Data]]]
|
|
|
|
|
def fetchMany(entityId: UUID, page: Page): Task[Paged[Event[Data]]]
|
2025-02-28 05:40:32 +01:00
|
|
|
|
|
|
|
|
trait Reducer[Event, State]:
|
2025-03-03 00:24:13 +01:00
|
|
|
def fromEmpty: PartialFunction[Event, State]
|
|
|
|
|
def fromState: PartialFunction[(State, Event), State]
|
2025-02-28 05:40:32 +01:00
|
|
|
|
2025-03-03 00:24:13 +01:00
|
|
|
def reduce(event: Event): Option[State] =
|
|
|
|
|
fromEmpty.lift(event)
|
|
|
|
|
|
|
|
|
|
def reduce(state: State, event: Event): Option[State] =
|
|
|
|
|
fromState.lift((state, event))
|
|
|
|
|
|
|
|
|
|
trait CommandHandler[+Command, +Event, +State]:
|
2025-02-28 05:40:32 +01:00
|
|
|
def name: String
|
2025-03-03 00:24:13 +01:00
|
|
|
def isCreate: Boolean
|
|
|
|
|
inline def isUpdate: Boolean = !isCreate
|
|
|
|
|
def commandSchema: Schema[?]
|
2025-02-28 05:40:32 +01:00
|
|
|
|
2025-03-03 00:24:13 +01:00
|
|
|
trait CommandHandlerCreate[Command: Schema, Event] extends CommandHandler[Command, Event, Nothing]:
|
2025-02-28 05:40:32 +01:00
|
|
|
def onCommand(entityId: UUID, command: Command): Task[Event]
|
2025-03-03 00:24:13 +01:00
|
|
|
val isCreate = true
|
|
|
|
|
val commandSchema = summon[Schema[Command]]
|
2025-02-28 05:40:32 +01:00
|
|
|
|
2025-03-03 00:24:13 +01:00
|
|
|
trait CommandHandlerUpdate[Command: Schema, Event, State]
|
|
|
|
|
extends CommandHandler[Command, Event, State]:
|
2025-02-28 05:40:32 +01:00
|
|
|
def onCommand(entityId: UUID, state: State, command: Command): Task[Event]
|
2025-03-03 00:24:13 +01:00
|
|
|
val isCreate = false
|
|
|
|
|
val commandSchema = summon[Schema[Command]]
|
2025-02-28 05:40:32 +01:00
|
|
|
|
|
|
|
|
class CommandEngine[Command, Event, State](
|
|
|
|
|
val handlers: List[CommandHandler[Command, Event, State]],
|
|
|
|
|
reducer: Reducer[Event, State],
|
|
|
|
|
val eventRepo: EventRepository[Event],
|
|
|
|
|
val stateRepo: StateRepository[State]):
|
|
|
|
|
|
2025-03-03 00:24:13 +01:00
|
|
|
def handleCommand(command: Command, name: String, entityId: UUID)
|
|
|
|
|
: Task[(lu.foyer.Event[Event], lu.foyer.Entity[State])] =
|
2025-02-28 05:40:32 +01:00
|
|
|
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"))
|
2025-03-03 00:24:13 +01:00
|
|
|
newEntity = Entity(entityId, newState, entityOption.map(_.version).getOrElse(1))
|
|
|
|
|
_ <- if entityOption.isEmpty then stateRepo.insert(newEntity.entityId, newEntity)
|
2025-02-28 05:40:32 +01:00
|
|
|
else stateRepo.update(newEntity.entityId, newEntity)
|
|
|
|
|
eventEntity <- Random.nextUUID.map(Event(newEntity.entityId, event, _))
|
2025-03-03 00:24:13 +01:00
|
|
|
_ <- eventRepo.insert(eventEntity.eventId, eventEntity)
|
|
|
|
|
yield (eventEntity, newEntity)
|
2025-02-28 05:40:32 +01:00
|
|
|
|
|
|
|
|
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
|
2025-03-03 00:24:13 +01:00
|
|
|
case (None, h) if !h.isUpdate =>
|
|
|
|
|
h.asInstanceOf[CommandHandlerCreate[Command, Event]]
|
|
|
|
|
.onCommand(entityId, command)
|
2025-02-28 05:40:32 +01:00
|
|
|
.map(event => (event, reducer.reduce(event)))
|
2025-03-03 00:24:13 +01:00
|
|
|
case (Some(entity), h) if h.isUpdate =>
|
|
|
|
|
h.asInstanceOf[CommandHandlerUpdate[Command, Event, State]]
|
|
|
|
|
.onCommand(entityId, entity.data, command)
|
2025-02-28 05:40:32 +01:00
|
|
|
.map(event => (event, reducer.reduce(entity.data, event)))
|
2025-03-03 00:24:13 +01:00
|
|
|
case (Some(_), h) if !h.isUpdate =>
|
2025-02-28 05:40:32 +01:00
|
|
|
ZIO.fail(
|
|
|
|
|
new IllegalArgumentException(s"State already exists when applying create command $name")
|
|
|
|
|
)
|
2025-03-03 00:24:13 +01:00
|
|
|
case (None, h) if h.isUpdate =>
|
2025-02-28 05:40:32 +01:00
|
|
|
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:
|
2025-03-03 00:24:13 +01:00
|
|
|
def layer[Command: Tag, Event: Tag, State: Tag] =
|
2025-02-28 05:40:32 +01:00
|
|
|
ZLayer.fromFunction(CommandEngine[Command, Event, State](_, _, _, _))
|