Learning Scala: Folding in Traceability

In enterprise commerce, totals don’t drift because someone forgot algebra. They drift because reality changes: promos expire, eligibility changes when an address arrives, catalog data updates, substitutions happen, and returns unwind prior discounts. When someone asks “why did the total change?” you need more than narration. You need evidence — a trail of facts you can replay and a pure computation that deterministically produces the same result. That is the job foldLeft is quietly good at. In Scala collections, foldLeft walks left-to-right and is the safest default because it doesn’t rely on recursion and it behaves predictably with large collections. foldRight walks right-to-left and, depending on the collection, can be less efficient or even blow the stack. Plain fold exists when the operation is associative (order doesn’t matter) and particularly useful in parallel computations; in real business code, direction usually matters, so I’m using foldLeft deliberately.

This is not an argument for (or against) event sourcing. It’s a smaller, more practical pattern: record the decisions your system makes as immutable facts, then compute the answer from those facts with pure functions. When you do that, “what happened?” becomes “run the same computation over the same facts.” You stop guessing, and you stop doing archaeology in logs.

New to this series?

Catch up on earlier posts to follow along with the Functional Programming Isn’t Just for Academics series:

Each post in this series explores how teams use Scala to build applications that stay clean, testable, and easy to scale.

Imagine a cart where the system makes a series of decisions over time: items come and go, promotions are either accepted or rejected for specific reasons, and restrictions may only become knowable until context is provided. At checkout time you need the current totals, but you also need the story that makes the totals defensible later. If you only persist the final state, you can often show the number, but you can’t reliably explain it.

Start by modeling facts.

Scala
 import java.time.Instant

final case class SkuId(value: String)

final case class Money(value: BigDecimal):
def +(m: Money) = Money(value + m.value)
def -(m: Money) = Money(value - m.value)

object Money:
val zero = Money(0)

final case class CartLine(sku: SkuId, qty: Int, unitPrice: Money)

sealed trait CartEvent:
def at: Instant

object CartEvent:
final case class ItemAdded(at: Instant, line: CartLine) extends CartEvent
final case class ItemRemoved(at: Instant, sku: SkuId) extends CartEvent

final case class PromoApplied(at: Instant, promoId: String, discount: Money) extends CartEvent
final case class PromoRejected(at: Instant, promoId: String, reason: String) extends CartEvent

final case class ShippingRestricted(at: Instant, sku: SkuId, reason: String) extends CartEvent

In Java terms, sealed is the closest thing to a closed interface with known implementations. The point is not style; it’s that you can add a new event later and the compiler can help you find every place you forgot to handle it.

Now define the answer you want to compute.

Scala
 final case class CartView( lines: Map[SkuId, CartLine], appliedDiscounts: List[(String, Money)], rejections: List[(String, String)], restrictions: List[(SkuId, String)] ): def subtotal: Money = lines.values.foldLeft(Money.zero) { (acc, line) => acc + Money(line.unitPrice.value * BigDecimal(line.qty)) }

def discountTotal: Money =
appliedDiscounts.foldLeft(Money.zero) { case (acc, (_, d)) => acc + d }

def total: Money =
subtotal - discountTotal

object CartView:
val empty = CartView(Map.empty, Nil, Nil, Nil)

The discipline here is simple: the view is a derived result, not a mutable record you “keep up to date” and hope remains consistent. If you can recompute it from facts, you can prove it.

The next step is a function that takes the current view plus one event and returns a new view. Scala’s match is closest to switch, except you can switch on the shape of a value (for example, “an ItemAdded event with a line”) rather than only primitives.

Scala
 def applyEvent(view: CartView, e: CartEvent): CartView = e match case CartEvent.ItemAdded(_, line) => view.copy(lines = view.lines.updated(line.sku, line))
case CartEvent.ItemRemoved(_, sku) =>
  view.copy(lines = view.lines - sku)

case CartEvent.PromoApplied(_, promoId, discount) =>
  view.copy(appliedDiscounts = (promoId -> discount) :: view.appliedDiscounts)

case CartEvent.PromoRejected(_, promoId, reason) =>
  view.copy(rejections = (promoId -> reason) :: view.rejections)

case CartEvent.ShippingRestricted(_, sku, reason) =>
  view.copy(restrictions = (sku -> reason) :: view.restrictions)

Two small but important details if you’re reading this as a Java developer. First, copy(...) constructs a new instance with updated fields; it does not mutate the old one. Second, updated returns a new map with one key changed, and view.lines - sku returns a new map with that key removed.

Now compute the view from a journal. If you mentally translate foldLeft into a loop: start with an initial accumulator, apply a step function for each event, return the final accumulator.

Scala
 def computeView(events: List[CartEvent]): CartView = events.foldLeft(CartView.empty)(applyEvent)

// Equivalent shape:
// events.foldLeft(CartView.empty) { (acc, e) => applyEvent(acc, e) }

This pattern earns its keep in three places enterprise teams care about.

  • Reproducibility. When a customer disputes a total, or finance asks for justification, you can reconstruct the same answer from the same facts. That’s a different category of explanation than “we think this is what happened.”

  • Testability. Because applyEvent and computeView are pure, tests don’t need harnesses or mocking. You feed in events and assert the derived answer and the recorded reasons.

Scala
 val t0 = Instant.parse("2026-02-01T00:00:00Z")

val events = List(
CartEvent.ItemAdded(t0, CartLine(SkuId("sku-1"), 2, Money(50))),
CartEvent.PromoApplied(t0, "promo-10off", Money(10)),
CartEvent.ShippingRestricted(t0, SkuId("sku-1"), "cannot ship to CA")
)

val view = computeView(events)

assert(view.total == Money(90))
assert(view.restrictions.nonEmpty)
  • Evolution without erasing history. When requirements change, you can add a new event type and extend applyEvent. Old journals remain valid. New rules can derive new views. You didn’t have to rewrite the past to understand it.

In real systems, the most expensive failures aren’t always the ones that crash. They’re the ones that change outcomes and leave you unable to explain why. An immutable journal plus a pure fold is a pragmatic way to keep your system honest: not just in what it computed, but in what it can later prove.

 
Next
Next

Offshore Software Development: Strategy & Models