Skip to Content
Course content

98: Modeling Domains with Algebraic Data Types

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

I've spent a lot of time reviewing PRs from developers transitioning to Scala from Java or Python, and I consistently see one specific pattern: the "everything-bagel" class. They try to model a domain entity by creating a single class with a handful of optional fields and an enum to track the "type" of the object. It feels intuitive at first, but it's a trap that leads to a codebase full of if (type == CREDIT_CARD && cardNum.isDefined) checks.

The Trap: Using Optional Fields to Represent Different States

Imagine we are building a payment processing system. A common mistake is to model a PaymentMethod like this:

case class PaymentMethod(
  methodType: String, 
  cardNumber: Option[String], 
  paypalEmail: Option[String], 
  bankAccount: Option[String]
)

On the surface, this looks flexible. But look closer: what's stopping me from creating a PaymentMethod where methodType is "PayPal" but the paypalEmail is None? Or worse, a payment method that has both a credit card number and a bank account? The compiler can't help you here. You've created a model where "illegal states" are perfectly representable. You'll end up spending half your time writing validation logic to ensure your data is actually consistent.

The Fix: Making Illegal States Unrepresentable with Sum Types

In Scala, we use Algebraic Data Types (ADTs) to solve this. An ADT is essentially a combination of Product Types (case classes) and Sum Types (sealed traits). Instead of one bloated class, we define a closed hierarchy where a value is exactly one of a few specific options.

Here is how I would actually model that payment system:

sealed trait PaymentMethod

case class CreditCard(number: String, expiry: String) extends PaymentMethod
case class PayPal(email: String) extends PaymentMethod
case class BankTransfer(accountNumber: String, routingNumber: String) extends PaymentMethod

Notice the sealed keyword. This is the secret sauce. It tells the compiler that all implementations of PaymentMethod are defined in this file. This transforms the trait into a "Sum Type"β€”the payment method is either a CreditCard OR a PayPal account OR a BankTransfer. It cannot be a weird hybrid of the three, and it cannot be "none of the above."

The real power hits when you actually use this data. Because the trait is sealed, the Scala compiler can perform exhaustiveness checking during pattern matching:

def processPayment(method: PaymentMethod): String = method match {
  case CreditCard(num, _) => s"Charging card $num"
  case PayPal(email)      => s"Redirecting to PayPal for $email"
  // If I forget BankTransfer here, the compiler will give me a warning!
}

I love this because it moves the burden of correctness from my brain (and my unit tests) to the compiler. If I add a CryptoWallet case class to the trait six months from now, the compiler will instantly point me to every single match statement in the entire application that needs to be updated to handle crypto payments.

When you're modeling your domain, stop asking "What fields does this object have?" and start asking "What are the distinct shapes this data can take?" Once you shift that perspective, you'll find you can delete a massive amount of defensive null-checking and validation code.




πŸ“‹ Practical Task

Implementing a Flight Booking State Machine

You are tasked with modeling the lifecycle of a flight booking. A booking isn't just a set of fields; it exists in different states, and each state carries different data.

Requirements:

  • Create a sealed trait called BookingState.
  • Implement three case classes that extend this trait:
    • Pending: Contains only the flightNumber (String).
    • Confirmed: Contains the flightNumber (String) and a seatNumber (String).
    • Cancelled: Contains the flightNumber (String) and a reason (String).
  • Write a function getBookingSummary(state: BookingState): String that uses pattern matching to return:
    • "Flight [num] is awaiting payment" for Pending.
    • "Flight [num] is confirmed in seat [seat]" for Confirmed.
    • "Flight [num] was cancelled due to [reason]" for Cancelled.

Ensure your code compiles and that the BookingState trait is properly sealed to enable exhaustiveness checking.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.