Learning Scala: Why Commerce Is Verbs, Not Nouns

A customer wants to send something back. The instinct, encoded in a thousand systems, is to treat that as running the order backwards. Find the row. Reverse it. Refund the card. Put the item back on the shelf. The mental model is arithmetic: an order adds something, a return subtracts it, and if the books balance, the job is done.

That model is wrong, and it costs money in a way that's structural, not occasional. You can't restock a perishable. A case of berries shipped, the return arrived four days later, and reversing the row says inventory plus one when reality says inventory plus zero, plus a liability you now have to dispose of. You shouldn't refund a fraud return either. Item comes back, money goes out, is exactly what an organized refund-fraud ring is counting on, and if return means refund in your data model, you've built their happy path for them.

An exchange isn't a refund plus a new order. Treat it that way and you've charged the customer's card twice, held two authorizations, and turned a goodwill gesture into a dispute. The customer experienced one moment. Your books recorded three. And a destroyed item still costs something. A product comes back damaged, or it's a hazmat item that can't legally re-enter inventory, or the cost of refurbishing it exceeds its resale value. The order said I gave you this. Subtracting one from inventory isn't a decision about what happens next. It's an evasion of one.

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.

Why a Status Column Can't Answer What a Dispute Actually Asks

Reversing a row assumes a return has one shape, and it has at least eight. We've spent forty years modeling commerce with nouns instead of verbs, and the same failure shows up on the order itself, with no return in sight.

A dispute lands ninety days after the box was delivered. The cardholder's bank wants to know what was agreed: the price, the currency, whether the customer authorized a second shipment, whether the partial refund they claim never arrived was actually issued. You open the order. The record says status = 'refunded', total = 0.00, updated_at = last Tuesday. Every field is true. None of them answers the question, because the question was never what is this order now. It was what did you promise, and when, and a status column only ever knew the present.

Put the return case and the dispute case side by side and the shared defect is obvious. Both treat commerce as if it produced nouns, an order, a return, each a thing with attributes you update as the world changes. Neither is a noun. Both are the residue of a sequence of decisions someone made, at a moment, for a reason: authorize this charge, capture these funds, ship these lines, deny this return, restock this item, refund that one, destroy this other one. A row holds exactly one of those decisions at a time, whichever happened most recently, and calls that the order. Everything upstream of the latest update is gone, not archived, not softened, gone, because the row was never a place decisions could accumulate. It was a place they overwrote each other.

Modeling Decisions as Events Instead of Order State

Reverse the row and read off the status column are the same mistake wearing two different names. Both ask a cell of state to answer a question about history, and a cell of state, by construction, doesn't have one. Record the decisions themselves, as a closed, named vocabulary, and let the order and the return be nothing more than what those decisions add up to.

scala

enum OrderEvent:
  def at: Instant
  case Placed(at: Instant, lines: List[(Sku, Int)], customer: CustomerId)
  case Priced(at: Instant, lineTotals: Map[Sku, Money], grand: Money)
  case PaymentAuthorized(at: Instant, processorRef: String, amount: Money)
  case PaymentCaptured(at: Instant, amount: Money)
  case Shipped(at: Instant, lines: List[(Sku, Int)], carrier: String)
  case ReturnRequested(at: Instant, line: LineItem, reason: ReturnReason)
  case ItemInspected(at: Instant, sku: Sku, condition: ItemCondition)
  case DispositionDecided(at: Instant, line: LineItem, decision: ReturnDecision)
  case RefundIssued(at: Instant, amount: Money, to: PaymentMethod)
  case ItemRestocked(at: Instant, sku: Sku, condition: ItemCondition)
  case ItemDestroyed(at: Instant, sku: Sku, reason: DestroyReason)
  

Every case is a verb, not a field: something a person or a process decided, at a specific time. The return doesn't get its own table or its own object. ReturnRequested, ItemInspected, DispositionDecided, ItemRestocked, and ItemDestroyed sit in the same list as Placed and Shipped. A return was never a different kind of thing from an order. It's more decisions in the same sequence.

Notice what none of those verbs belong to, either. Not one is a method reaching into an order and mutating its own field. Each is something the business does, using the order as its reference, never as its actor. An order doesn't ship itself any more than a tax rate applies itself, which is the deeper reason CRUD was always the wrong frame. Create, read, update, delete describe what a database does to a record, and no business runs on those four verbs. It runs on authorize, capture, ship, inspect, decide, refund, restock, destroy, each one a capability the business exercises, not an operation a table exposes.

Disposition Is a Family of Outcomes, Not Just Refund

Returns are the case that makes this naming exercise unavoidable, because the naive model wants exactly one verb, refund, and reality has at least eight. Stop thinking about reversal and start thinking about disposition: what, concretely, is going to happen to the money, the item, and the relationship.

scala

enum Disposition:
  case Refund(amount: Money, to: PaymentMethod)
  case Exchange(forItem: Sku, priceDelta: Money)
  case StoreCredit(amount: Money)
  case Restock(condition: ItemCondition)
  case Refurbish(estimatedCost: Money)
  case Destroy(reason: DestroyReason)
  case WarrantyReplace(claim: WarrantyClaim)
  case Goodwill(gesture: Gesture)
  // keep the item, refund anyway
  

A refund is one member of this family. It's the most visible member, which is why teams mistake it for the whole. But restocking an item without refunding the packaging cost, letting a customer keep a four-dollar item and crediting them instead of paying twelve dollars to ship it back, destroying a returned mattress for hygiene law and eating the cost, replacing under warranty without any money moving at all: these aren't edge cases bolted onto refund. They're siblings. Refund has no special claim to be the default. It's just the one the naive model could express.

The instant the family becomes a sum type, the compiler starts working for you. Add WarrantyReplace and every place that decides a disposition has to say what it does with that case. There's no silent fall-through to refund the card, which is precisely the fall-through that pays the fraud ring and double-charges the exchange.

Computing a Disposition Instead of Reading One Off

A disposition isn't read off the item. It's computed, from what the customer is asking, what policy permits, and what condition the goods actually came back in.

scala

final case class ReturnRequest(
  order:           OrderRef,
  line:            LineItem,
  reason:          ReturnReason,
  requestedRemedy: Option[Disposition]
  // what the customer asked for
)
 
final case class ItemContext(
  condition:          ItemCondition,
  // Sellable, Damaged, Perishable, Hazmat
  daysSinceDelivery:  Int,
  resaleValue:        Money,
  returnShippingCost: Money
)
 
def decideDisposition(
  req:    ReturnRequest,
  ctx:    ItemContext,
  policy: ReturnPolicy
): ReturnDecision
  

That function changes nothing in the world. It doesn't refund a card or move inventory. It reads three inputs and returns a value, which means you can run it a thousand times for free: on every line of a basket, on a what-if for customer service, on last year's returns to see what a policy change would have done. None of those calls touch money or stock. The dangerous part, the part that actually refunds and restocks, happens later, once, against a decision already made.

Why Return Decisions Need More Than Approve or Deny

The naive model has exactly two endings: the return succeeds and the money goes out, or something throws. The most expensive return bugs live in the gap between those two.

scala

enum ReturnDecision:
  case Resolve(disposition: Disposition)
  case Deny(reason: PolicyReason)
  case RequireInspection(then: InspectionId)
  // decide after we see it
  case RequireApproval(by: Role, because: String)
  case Quote(options: List[Disposition])
  // let the customer choose
  

Deny is a value here, not an exception, and that's the point. It's the fraud guard made explicit: a return outside the window, on a final-sale item, with a reason that doesn't qualify. The honest answer is no, and no has to be something the system can return without anyone treating it as an error to smooth over. RequireInspection is the perishable and damage case: you can't decide the disposition of a thing you haven't seen, and the model has to be able to say the answer depends on the item's condition, and I don't know it yet, rather than guess restock and be wrong.

RequireApproval is the high-value and policy-exception case. A forty-dollar goodwill credit goes through automatically. A four-thousand-dollar one gets a human signature. The threshold is policy. The shape, that some decisions route to a person, is structure. Quote is the exchange and store-credit fork, where the right outcome isn't one disposition but a small menu the customer picks from.

Every caller of decideDisposition has to pattern-match all five. The refund path, the deny path, and the not-yet-known path can't quietly collapse into each other, because the compiler won't let a RequireInspection be spent as if it were a Resolve(Refund(...)). Treating we should look at this first as refund it now is exactly the bug the naive arithmetic model can't even see, because it never named the case in the first place.

State Is What You Fold, Not What You Store

Once decisions are named instead of nouns updated, the status column you used to overwrite becomes a value you recompute from decisions you never destroyed.

scala

final case class OrderState(
  id:         OrderId,
  status:     Status,
  priced:     Option[Money],
  authorized: Option[Money],
  captured:   Money,
  refunded:   Money,
  shipped:    Map[Sku, Int],
  returned:   Map[Sku, Int]
)
 
enum Status:
  case Draft, Priced, Authorized, Captured, Shipped, Closed
 
def apply(s: OrderState, e: OrderEvent): OrderState = e match
  case OrderEvent.Priced(_, _, grand) =>
    s.copy(status = Status.Priced, priced = Some(grand))
  case OrderEvent.PaymentCaptured(_, amt) =>
    s.copy(status = Status.Captured, captured = add(s.captured, amt))
  case OrderEvent.Shipped(_, lines, _) =>
    s.copy(status = Status.Shipped, shipped = merge(s.shipped, lines))
  case OrderEvent.RefundIssued(_, amt, _) =>
    s.copy(refunded = add(s.refunded, amt))
  case _ => s
  // the remaining cases follow the same shape
 
def project(id: OrderId, history: List[OrderEvent]): OrderState =
  history.sortBy(_.at).foldLeft(OrderState.empty(id))(apply)
  

What is this order now becomes fold all of them, and there's no separate question of how to keep the history, because the history is the only thing stored. apply is one small, pure function: read a state and one decision, compute the consequence.

The type in that snippet is named OrderEvent for a reason. A decision recorded this way, immutable, timestamped, appended and never edited, is what most engineers call an event, and the discipline of deriving state by replaying them is called event sourcing. The name isn't the point. The point is what naming the decisions bought before the vocabulary ever came up: a history nothing could overwrite.

Why Folding Events Makes Disputes Reproducible

The dispute stops being a forensic exercise across four systems and becomes a filter over one list. The price you quoted, the moment the card was authorized, the exact amount and timestamp of a partial refund are decisions sitting in order, because they were never anything else, and there's only one account of them, not a live row with a worried audit copy kept beside it.

The return decision becomes reproducible for the same reason. decideDisposition reads a projected OrderState and an ItemContext. If both are folds of decisions up to the moment it ran, feeding it the same history produces the same answer today, in a test, in front of an auditor, that it produced in production. A decision made against a mutable row can't be reproduced, because its inputs no longer exist. Time travel is the same fold, cut earlier: the order's state at any past instant is the decisions up to that instant, which turns what did this look like the night before the chargeback from unanswerable into a change to where the sequence gets cut.

None of this means folding the entire history on every page load. Keep a projection, the current OrderState, cached and updated as each decision lands, so reads stay cheap. Only the source of truth changes. The cache is disposable, the sequence isn't, and if the cache is ever wrong you rebuild it by folding again.

What a return adds to the sequence, request, inspection, decision, refund, never rewrites what came before it. That's what makes the hard dispositions tractable. Destroy isn't inventory minus one and forget it. It's a decision that records a cost, a reason, and often a legal obligation. Goodwill is refund issued, no item restocked, on purpose, a deliberate asymmetry the books need to see, not a discrepancy to reconcile away at month end. Modeled as decisions in a sequence that only ever grows, both are ordinary entries. Modeled as subtraction from a row, both are errors, because subtraction can't represent we paid and got nothing back, and that was the right call.

Commerce was never a set of nouns. It happens in verbs, authorize, capture, ship, request, inspect, decide, refund, restock, destroy, replace, approve, deny, each one a decision made at a moment, each one true forever after it's made. A row can hold the residue of one of those decisions at a time, whichever happened last, and call that the order. It can't hold the decisions themselves, which is exactly what a dispute, an audit, or a customer swearing they were never refunded needs back.

This is Part 21 in an ongoing series. If you found this useful, Part 20 covers why configuration is code with a weaker deployment process, using a tax rate typo to make the case for typed validation before anything ships. Read "Configuration Is Code with No Deploy Gate"

Frequently Asked Questions

Why can't a return be modeled as an order running in reverse?

Reversing a row assumes every return has the same shape as the order it undoes, and that assumption breaks constantly. A perishable that shipped days ago can't be restocked, a fraud return shouldn't trigger a refund, and an exchange is not a refund plus a new order. Treating all of these as arithmetic on the same row produces the wrong outcome in each case.

What is event sourcing and why does it fit commerce systems?

Event sourcing means recording each decision, such as authorizing a payment or issuing a refund, as an immutable, timestamped fact instead of overwriting a status column. Current state is derived by folding those facts in order. Commerce fits this well because disputes, audits, and returns all depend on knowing exactly what was decided and when, not just what the system looks like right now.

What is a disposition in return processing?

A disposition is the actual outcome of a return decision, and refund is only one member of that family. Restock, exchange, store credit, refurbish, destroy, warranty replacement, and goodwill are all valid dispositions with different effects on money and inventory. Modeling them as a single sum type forces every code path to handle each case explicitly instead of defaulting to refund.

Why should deciding a disposition be a pure function?

A pure function that computes a disposition from a request, an item's condition, and a policy can be run as many times as needed without side effects, including in tests, what-if simulations, and audits. The action that actually moves money or inventory happens separately, once, against a decision that has already been made, which keeps the risky part of the system small and isolated.

Why does a return decision need a deny and a not-yet-known outcome, not just approve or refund?

A naive model has two endings, success or an exception, and the real cost hides in that gap. A return decision needs to express deny for fraud and policy violations, require inspection when the condition is not yet known, require approval for high-value exceptions, and quote when the customer picks from options. Collapsing any of these into an automatic refund is exactly the bug that costs money.

How does folding events instead of storing state help with disputes?

When state is derived by folding a sequence of events, a dispute becomes a filter over that sequence instead of a forensic search across systems. The price quoted, the moment a payment was authorized, and the exact amount of a partial refund are all decisions sitting in order, and replaying the same events always produces the same answer, which is what an auditor or a chargeback needs.

 
Next
Next

Learning Scala: Configuration Is Code with No Deploy Gate