Skip to Content
Course content

159: Building an Order Processing System with Akka Persistence

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

By now, you're comfortable with Akka actors, but in a real-world production system, "in-memory" isn't enough. If your node crashes, your state is gone. You might be tempted to just throw a database call inside your actor, but that's a recipe for bottlenecks and consistency nightmares. This is where Akka Persistence comes in. Instead of saving the current state, we save the sequence of events that led to that state. This is Event Sourcing.

Defining our Order Domain

I want us to build a simple Order Processing system. We aren't just tracking a total; we're tracking the lifecycle of an order. I'll start by defining the commands (what we want to do) and the events (what actually happened). It's a subtle distinction, but it's the most important part of persistence.

case class OrderItem(sku: String, price: Double)

// Commands: The "Intent"
sealed trait OrderCommand
case class CreateOrder(orderId: String, customerId: String) extends OrderCommand
case class AddItem(item: OrderItem) extends OrderCommand
case class ShipOrder() extends OrderCommand

// Events: The "Fact"
sealed trait OrderEvent
case class OrderCreated(orderId: String, customerId: String) extends OrderEvent
case class ItemAdded(item: OrderItem) extends OrderEvent
case class OrderShipped() extends OrderEvent

// State: The "Result"
case class OrderState(
  orderId: Option[String] = None,
  items: List[OrderItem] = Nil,
  isShipped: Boolean = false
)

Building the EventSourcedBehavior

Now, let's wire this up. We use EventSourcedBehavior. The magic here is the separation between the commandHandler (which decides if an action is allowed) and the eventHandler (which actually updates the state). I like to think of the command handler as the "gatekeeper" and the event handler as the "bookkeeper."

import akka.persistence.typed.scaladsl.{Effect, EventSourcedBehavior}
import akka.persistence.typed.PersistenceId

object OrderActor {
  def apply(orderId: String): Behavior[OrderCommand] = {
    EventSourcedBehavior[OrderCommand, OrderEvent, OrderState](
      persistenceId = PersistenceId.ofUniqueId(orderId),
      emptyState = OrderState(),
      commandHandler = (state, cmd) => cmd match {
        case CreateOrder(id, _) => 
          Effect.persist(OrderCreated(id, "cust-123"))
        case AddItem(item) if !state.isShipped => 
          Effect.persist(ItemAdded(item))
        case ShipOrder() if state.items.nonEmpty => 
          Effect.persist(OrderShipped())
        case _ => 
          Effect.ignore // Or send a failure message back to the sender
      },
      eventHandler = (state, event) => event match {
        case OrderCreated(id, _) => state.copy(orderId = Some(id))
        case ItemAdded(item)     => state.copy(items = state.items :+ item)
        case OrderShipped()      => state.copy(isShipped = true)
      }
    )
  }
}

A mistake I almost left in: The Side-Effect Trap

Here is where I usually trip up when I'm moving too fast. I originally tried to trigger the "Shipping Email" notification inside the eventHandler. It looked like this:

// WRONG WAY
eventHandler = (state, event) => event match {
  case OrderShipped() => 
    EmailService.sendShippingConfirmation(state.orderId) // DANGER!
    state.copy(isShipped = true)
}

I realized my mistake during a restart test. Because Akka Persistence replays every single event from the journal to rebuild the state when an actor starts, my customer suddenly received five shipping emails because the actor crashed and recovered five times. Never put side-effects in the event handler. The event handler must be a pure function.

To fix this, I move the side-effect to the commandHandler using Effect.persist combined with a callback, or better yet, by handling the logic after the event has been successfully persisted.

Handling Side Effects Correctly

If you need to do something in the real world (like hitting an API) after an event is saved, you use the Effect.persist block's ability to execute a callback. Here is the corrected ShipOrder logic:

case ShipOrder() if state.items.nonEmpty => 
  Effect.persist(OrderShipped())
    .thenRun { 
      // This only runs once when the event is first persisted,
      // NOT during recovery.
      EmailService.sendShippingConfirmation(state.orderId) 
    }

Now the system is robust. We have a permanent audit log of everything that happened to the order, the state is recoverable, and we aren't spamming our customers every time the JVM restarts.




📋 Practical Task

Implement Order Validation and Refund Logic

Your task is to extend the OrderActor system we built to handle refunds, but with a strict business rule: an order cannot be refunded if it hasn't been shipped yet.

  • Create a new command RequestRefund and a corresponding event OrderRefunded.
  • Update the OrderState to include a isRefunded: Boolean field.
  • Implement the logic in the commandHandler to ensure RequestRefund only persists an event if state.isShipped is true and state.isRefunded is false.
  • Update the eventHandler to update the state when OrderRefunded occurs.
  • Add a side-effect using .thenRun that logs "Processing payment return for order X" only when the refund is successfully persisted.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.