object PlaneSeatsExample {
sealed trait SeatClass
case object Economy extends SeatClass
case object Business extends SeatClass
type SeatId = String
type TicketId = Long
case class Seat(id: SeatId, seatClass: SeatClass, booking: Option[Booking])
case class Booking(ticketId: TicketId)
case class Flight(seats: Map[SeatId, Seat], bookingClosed: Boolean, doubleBookings: List[Booking])
// commands and queries
sealed trait BookingCommand
case class QueryFreeSeats(seatClass: SeatClass) extends BookingCommand
case class FreeSeats(seats: Seq[Seat])
case class GetBooking(seatId: SeatId) extends BookingCommand
case class GetBookingResponse(seatId: SeatId, booking: Option[Booking])
case class BookSeat(seatId: SeatId, ticketId: TicketId) extends BookingCommand
case class BookingConfirmed(booking: BookSeat)
case class BookingFailed(booking: BookSeat, reason: String)
case class CancelBooking(id: SeatId, ticketId: TicketId) extends BookingCommand
case class CancellationConfirmed(cancellation: CancelBooking)
case object CloseBooking extends BookingCommand
// events
sealed trait BookingEvent
case class SeatBooked(seatId: SeatId, booking: Booking) extends BookingEvent
case class BookingCancelled(seatId: SeatId, booking: Booking) extends BookingEvent
case object BookingClosed extends BookingEvent
class FlightSeating(createEmptyFlight: () => Flight) extends ReplicatedEntity[BookingCommand, BookingEvent, Flight] {
def initialState: Flight = createEmptyFlight()
def commandHandler: CommandHandler = CommandHandler.byState {
case f: Flight if f.bookingClosed => bookingClosed
case _ => bookingOpen
}
def bookingClosed: CommandHandler = CommandHandler {
case (ctx, _, _) =>
ctx.sender() ! BookingClosed
Effect.none
}
def bookingOpen: CommandHandler = CommandHandler {
case (ctx, state, QueryFreeSeats(seatClass)) =>
ctx.sender() ! FreeSeats(state.seats.values.filter(s => s.seatClass == seatClass && s.booking.isEmpty).toSeq)
Effect.none
case (ctx, state, booking @ BookSeat(seatId, ticketId)) =>
// validate
state.seats.get(seatId) match {
case Some(seat) if seat.booking.isDefined =>
// here we could find the next free seat of same class instead
ctx.sender() ! BookingFailed(booking, s"Seat $seatId already booked")
Effect.none
case Some(seat) =>
Effect.persist(SeatBooked(seatId, Booking(ticketId)))
.andThen(_ => ctx.sender() ! BookingConfirmed(booking))
case None =>
ctx.sender() ! BookingFailed(booking, s"No such seat $seatId on this flight")
Effect.none
}
case (ctx, state, cancellation @ CancelBooking(seatId, _)) =>
state.seats.get(seatId) match {
case Some(Seat(`seatId`, _, Some(booking))) =>
Effect.persist(BookingCancelled(seatId, booking))
.andThen(_ =>
ctx.sender() ! CancellationConfirmed(cancellation))
case _ =>
// no such booking, this is fine...
ctx.sender() ! CancellationConfirmed(cancellation)
Effect.none
}
case (ctx, state, GetBooking(seatId)) =>
val booking = state.seats.get(seatId) match {
case Some(seat: Seat) => seat.booking
case _ => None
}
ctx.sender() ! GetBookingResponse(seatId, booking)
Effect.none
case (ctx, state, CloseBooking) =>
Effect.persist(BookingClosed)
}
def eventHandler(flight: Flight, event: BookingEvent): Flight = {
event match {
case SeatBooked(seatId, booking) =>
val seat = flight.seats(seatId)
seat.booking match {
case None =>
// normal case
val booked = seat.copy(booking = Some(booking))
flight.copy(seats = flight.seats + (seatId -> booked))
case Some(existingBooking) =>
// conflict, someone else booked the seat in another dc
// deterministic reassign goes here, in our case, lower ticket id
// always win, and double booked person is put on rest list for manual/
// external handling
if (existingBooking.ticketId < booking.ticketId) {
// just add the new booking to the rest list
flight.copy(doubleBookings = booking :: flight.doubleBookings)
} else {
flight.copy(
// replace the existing booking
seats = flight.seats + (seatId -> seat.copy(booking = Some(booking))),
// and put on rest list
doubleBookings = existingBooking :: flight.doubleBookings)
}
}
case BookingCancelled(seatId, cancelledBooking) =>
flight.seats.get(seatId) match {
case Some(seat) if seat.booking.contains(cancelledBooking) =>
flight.copy(seats = flight.seats + (seatId -> seat.copy(booking = None)))
case _ =>
// already cancelled, or cancelled and rebooked, that is fine
flight
}
case BookingClosed =>
flight.copy(bookingClosed = true)
}
}
}
def flightProps(flightId: String, createEmptyFlight: () => Flight, settings: PersistenceMultiDcSettings): Props =
ReplicatedEntity.props("flight", flightId, () => new FlightSeating(createEmptyFlight), settings)
}