Skip to Content
Course content

247: Practice Exercise: Modeling a Booking System with ADTs

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember the first time I tried to build a booking system for a travel app a few years back. My instinct was to create one big Booking class with every possible field: flightNumber, roomNumber, seatAssignment, and a bunch of booleans like isCancelled or isConfirmed. It felt efficient at the time, but it quickly became a nightmare of if (booking.flightNumber != null) checks. I was essentially fighting the language to prevent invalid states.

The 'Everything Class' Trap

Let's start where I did. Imagine we want to track a booking that could be a Hotel or a Flight, and it can be either Pending, Confirmed, or Cancelled. Here is the "naive" way to do it:

case class Booking(
  id: String,
  hotelRoom: Option[Int],
  flightNumber: Option[String],
  status: String // "Pending", "Confirmed", "Cancelled"
)

Now, here is the problem. I can accidentally create a Booking that has both a hotelRoom and a flightNumber. Or, even worse, a booking that is "Cancelled" but still has a hotelRoom assigned. The compiler doesn't care; it just sees a bunch of Options and a String. I'm spending all my time writing validation logic to make sure the data makes sense, rather than actually building features.

Separating State from Identity

I realized I needed to stop treating the "status" as a piece of data and start treating it as a type. In Scala, this is where Algebraic Data Types (ADTs) come in. Instead of a String, let's use a sealed trait. I'm using sealed here because I want the compiler to yell at me if I forget to handle one of the statuses in a pattern match later.

sealed trait BookingStatus
case object Pending extends BookingStatus
case class Confirmed(confirmationCode: String) extends BookingStatus
case class Cancelled(reason: String) extends BookingStatus

Look at what happened here. Notice how Confirmed now carries the confirmationCode? In my previous version, that code would have been another nullable field in the main class. Now, it's physically impossible to have a confirmation code unless the booking is actually in the Confirmed state. That's a huge win for type safety.

Handling Diverse Booking Data

But we still have the Hotel vs. Flight issue. I can't just keep adding Options to the main class. I need the type of booking to dictate what data is available. I'll apply the same ADT pattern here:

sealed trait BookingDetails
case class HotelDetails(roomNumber: Int, hotelName: String) extends BookingDetails
case class FlightDetails(flightNumber: String, seat: String) extends BookingDetails

Now I can tie it all together. My Booking class becomes a lean container that holds the identity of the booking, the specific details of what was booked, and the current status.

case class Booking(
  id: String, 
  details: BookingDetails, 
  status: BookingStatus
)

Testing the Exhaustiveness

The real magic happens when I actually use this in a function. Let's say I want to send a notification to the customer. Because I used sealed traits, the compiler acts as my safety net.

def sendNotification(booking: Booking): String = {
  booking.status match {
    case Pending => "Your booking is being processed."
    case Confirmed(code) => s"Confirmed! Your code is $code."
    // I'll intentionally leave out 'Cancelled' for a second
  }
}

If I try to compile this, Scala will give me a warning: "match may not be exhaustive. It would fail on the following input: Cancelled(_) ". This is exactly what I want. The compiler is basically reminding me, "Hey, you forgot to handle the case where a user cancels their trip." I don't have to maintain a checklist of edge cases in my head; the type system does it for me.

By nesting these ADTs, I've moved the business rules from "runtime checks" (where they crash in production) to "compile-time constraints" (where they are fixed before the code ever runs).




📋 Practical Task

Exercise: Implementing a Multi-Tiered Loyalty Booking System

You are expanding the booking system to handle different loyalty tiers. Some bookings are "Standard," while others are "VIP" (which requires a membership ID) or "Corporate" (which requires a company tax ID).

Your Task:

  • Create a sealed trait called LoyaltyTier.
  • Implement three case classes/objects for LoyaltyTier: Standard (no extra data), VIP (contains a membershipId: String), and Corporate (contains a taxId: String).
  • Update the Booking case class from the lesson to include a tier: LoyaltyTier field.
  • Write a function calculateDiscount(booking: Booking): Double that uses pattern matching on the tier to return a discount rate:
    • Standard: 0.0
    • VIP: 0.15
    • Corporate: 0.10
  • Ensure your calculateDiscount function is exhaustive so that the compiler is satisfied.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.