Skip to Content
Course content

112: Building a Functional Domain Model for an E-Commerce System

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

When we talk about a "domain model," it's easy to fall into the trap of thinking about database tables. But in a functional style, we aren't modeling how data is stored; we're modeling the rules of the business. For this lesson, we're going to build a small piece of an e-commerce checkout system. We want to move an order from a "Basket" state to a "Paid" state, but only if everything is valid.

Defining the Core Entities

I like to start by defining the "nouns" of the system using case classes. We need a Product and an OrderItem. I'm using BigDecimal for the price because, as you probably know by now, using Double for money is a recipe for rounding nightmares that will keep you up at 3 AM.

case class Product(id: String, name: String, price: BigDecimal, stock: Int)
case class OrderItem(product: Product, quantity: Int)
case class Basket(items: List[OrderItem])

Simple enough. Now, the state of an order isn't just a string in a database; it's a set of distinct phases. This is where sealed traits shine. By using a sealed trait, the compiler can warn us if we forget to handle a specific order state in our logic.

sealed trait OrderStatus
case object Pending extends OrderStatus
case class Paid(transactionId: String) extends OrderStatus
case class Shipped(trackingNumber: String) extends OrderStatus
case class Cancelled(reason: String) extends OrderStatus

The "Quick and Dirty" Mistake

Now, let's implement the logic to calculate the total price. When I first started writing this, I did something like this. I'll show you the "wrong" way because it's exactly how most people start:

def calculateTotal(basket: Basket): BigDecimal = {
  basket.items.map { item =>
    if (item.product.stock < item.quantity) {
      throw new IllegalStateException(s"Not enough stock for ${item.product.name}")
    }
    item.product.price * item.quantity
  }.sum
}

Here is the problem: I've introduced a side effect (throwing an exception). This makes the function "partial"β€”it doesn't return a value for all possible inputs. If this is part of a larger pipeline, one out-of-stock item will crash the entire request. In a functional domain model, we want "total functions." We want the possibility of failure to be explicitly written in the type signature.

Refining the Model with Either

To fix this, I'm going to introduce a DomainError trait. Instead of crashing, we'll return an Either. This tells anyone using this function: "Hey, this might work, or it might fail with one of these specific errors."

sealed trait DomainError
case class OutOfStock(productName: String) extends DomainError
case class InvalidQuantity(itemId: String) extends DomainError

def calculateTotal(basket: Basket): Either[DomainError, BigDecimal] = {
  val totals = basket.items.map { item =>
    if (item.product.stock < item.quantity) Left(OutOfStock(item.product.name))
    else Right(item.product.price * item.quantity)
  }
  
  // We use sequence to turn List[Either[E, A]] into Either[E, List[A]]
  totals.sequence.map(_.sum) 
}

I'm using .sequence here (which you'll find in scala.util.chaining or via Cats/ZIO in larger projects, but for this example, assume we have a helper that flips the list and the Either). Now, the compiler forces me to handle the OutOfStock case later in the flow.

Connecting the Workflow

Finally, let's tie it all together. We want to take a basket, calculate the price, and if that succeeds, "pay" for it to transition the order to the Paid status. I'll use a for-comprehension to make this look like a sequential set of steps while maintaining functional purity.

case class Order(id: String, basket: Basket, status: OrderStatus)

def processPayment(order: Order, paymentGateway: PaymentGateway): Either[DomainError, Order] = {
  for {
    total <- calculateTotal(order.basket)
    transactionId <- paymentGateway.charge(total).toEither // Assume this returns Either
  } yield order.copy(status = Paid(transactionId))
}

By structuring the domain this way, the business logic is decoupled from the execution. The Order doesn't know how the PaymentGateway works; it just knows that if it gets a transactionId, it can transition to Paid. It's clean, testable, and most importantly, it doesn't crash unexpectedly.




πŸ“‹ Practical Task

Exercise: Implementing a Volume Discount Rule

Our e-commerce system currently charges the full price regardless of quantity. Your task is to update the domain model to include a volume discount: if a customer buys 10 or more of a single item, that specific item should receive a 10% discount.

Requirements:

  • Modify the calculateTotal function to apply a 10% discount to the item.product.price if item.quantity >= 10.
  • Ensure the function still returns an Either[DomainError, BigDecimal] to handle the OutOfStock scenario.
  • Create a test case with a Basket containing one item with a quantity of 5 (full price) and one item with a quantity of 12 (discounted price) to verify your logic.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.