Plane Seats Example

Scala
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)

}