Skip to Content
Course content

228: Practice Exercise: Building a Complete Order Management System End to End

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

We've hit the capstone of this section. You've got the syntax and the type system down, but now we have to glue it all together. When you're tasked with building something like an Order Management System (OMS), there's a strong temptation to fall back on patterns from Java or C#—specifically, the "Service" pattern where a single monolithic class manages everything from validation to database writes.

The "Everything-in-a-Service" Trap

If you were to approach this naively, you'd probably start with an OrderService class. Inside, you'd have a few mutable maps to act as an in-memory database, a bunch of var fields for tracking state, and a series of methods like placeOrder(), processPayment(), and shipOrder(). It looks like this:

class OrderService {
  var orders = Map.empty[Long, Order]
  var nextId = 1L

  def placeOrder(items: List[Item]): Order = {
    val order = Order(nextId, items, status = "PENDING")
    orders += (nextId -> order)
    nextId += 1
    order
  }

  def shipOrder(id: Long): Unit = {
    val order = orders(id)
    orders += (id -> order.copy(status = "SHIPPED"))
  }
}


I've seen this go sideways in production more times than I can count. The problem isn't that it doesn't work—it works great for the first two weeks. The problem is that the business logic is trapped inside a stateful object. If you want to test the shipping logic, you have to instantiate the whole service, seed it with data, and then assert against a mutable map. Furthermore, the status is just a String. What happens when someone typos "SHIPPED" as "SHIPED"? The compiler won't save you; your order just vanishes into a logical black hole.

Modeling Order Life-cycles as State Transitions

The better way—the Scala way—is to treat your order not as a record with a status field, but as a state machine using Algebraic Data Types (ADTs). Instead of a String, we define exactly what an order can be. An order that hasn't been paid for is fundamentally different from an order that is currently on a truck.

sealed trait OrderState
case class Pending(items: List[Item]) extends OrderState
case class Paid(items: List[Item], transactionId: String) extends OrderState
case class Shipped(items: List[Item], trackingCode: String) extends OrderState
case class Cancelled(reason: String) extends OrderState

case class Order(id: Long, state: OrderState)


Now, instead of a monolithic service mutating a map, we write pure functions that transition the state. I like to think of these as "domain transformations." You take a Pending order and a payment confirmation, and you return a Paid order. If the payment fails, you return an Either[PaymentError, Paid].

This shift changes everything. Your business logic is now composed of pure functions that are trivial to unit test because they don't depend on a database or a global service state. You're moving the validation from runtime (checking strings) to compile-time (checking types). You physically cannot call the shipOrder logic on a Pending order because the function signature requires a Paid order as input.

Where the Trade-offs Land

Is this more code? Yes. You're defining more traits and case classes than you would in the naive approach. But you're trading a small amount of initial boilerplate for a massive increase in maintainability. When the business comes to you in six months and says, "We need to add a 'Refunded' state," you don't have to hunt through a 2,000-line service class to find every if (status == "PAID") block. You just add a Refunded case to your ADT, and the compiler will literally point to every single place in your code that needs to be updated to handle that new state.

In a real-world system, you'd still have a "Repository" layer to handle the actual saving to a database, but that layer should be a thin wrapper. The "brain" of your application should be these pure transformations, keeping the messy side-effects of the outside world at the very edges of your system.




📋 Practical Task

Exercise: Implementing the Order State Machine

Your task is to build the core logic for an Order Management System using the ADT pattern discussed in the lesson. Avoid using mutable variables (var) or mutable collections inside your logic.

Requirements:

  • Define a sealed trait OrderState with the following case classes: Pending, Paid, Shipped, and Cancelled.
  • Create an Order case class that holds an id: Long and a state: OrderState.
  • Implement a PaymentProcessor object with a function processPayment(order: Order, transactionId: String): Either[String, Order]. This function should only allow Pending orders to transition to Paid. If the order is in any other state, return a Left("Invalid state for payment").
  • Implement a ShippingProvider object with a function markAsShipped(order: Order, trackingNumber: String): Either[String, Order]. This should only allow Paid orders to transition to Shipped.
  • Write a small main program that attempts to:
    1. Create a Pending order.
    2. Pay for it (should succeed).
    3. Ship it (should succeed).
    4. Try to pay for it again (should fail with the error message).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.