Morel Cookbook

Problem

I want to model a domain where events of different shapes share a life — an order, a payment against it, a refund. In SQL I'd have three tables and hope the foreign keys hold. In Morel I can have one list of events and a type system that makes "payment for an order that doesn't exist" unrepresentable. Plus I want to check a business rule — refunds never exceed payments — and have the answer land as a table I can look at.

Setup

datatype event =
    Order of { id : int, amount : real }
  | Payment of { orderId : int, amount : real }
  | Refund of { orderId : int, amount : real };

val events = [
  Order { id = 1, amount = 222.00 },
  Order { id = 2, amount = 840.00 },
  Payment { orderId = 1, amount = 222.00 },
  Payment { orderId = 2, amount = 500.00 },
  Refund { orderId = 2, amount = 100.00 },
  Order { id = 3, amount = 90.00 },
  Payment { orderId = 3, amount = 90.00 }
];

Example

Three projections — kind, amount, and a group-sum — as one pipeline:

fun kind (Order _)   = "order"
  | kind (Payment _) = "payment"
  | kind (Refund _)  = "refund";

fun amount (Order { id = _, amount = a })        = a
  | amount (Payment { orderId = _, amount = a }) = a
  | amount (Refund { orderId = _, amount = a })  = a;

from e in events
  yield { kind = kind e, amount = amount e }
  group kind
    compute { total = sum over amount };
val it =
  [{kind="refund",total=100.0},{kind="payment",total=812.0},
   {kind="order",total=1152.0}] : {kind:string, total:real} list

What's happening

datatype event = Order of … | Payment of … | Refund of … is an algebraic data type — a sum of product types. Each case is its own constructor with its own payload, and the compiler enforces exhaustiveness when you pattern-match. Add a fourth event kind, and every fun that matches event must gain a clause for it. That's the guarantee SQL gives you with foreign keys and nothing else: the shape is the schema, and the schema is the code.

Two helper functions — kind and amount — turn the typed events back into uniform fields Morel's query operators understand. That's the bridge: define the data as precisely as you can, then project to the flat shape your aggregates need. The query looks like a SQL query and the type definition looks like a Haskell one, and they live in the same file.

Small 0.8 gotcha to remember. Partial record patterns inside a constructor payload — Order { id, ... } — hit the same bug you'll have seen in recipe 02. Bind every field with =, even if with a wildcard (id = _, amount = a). Tedious for wide records, fine for three.

Variations

The business invariant as a query. For each distinct order ID, sum payments and refunds, assert one doesn't exceed the other. The ok column is your test — if any false shows up, you've got a bug:

fun orderId (Order { id = i, amount = _ })        = i
  | orderId (Payment { orderId = i, amount = _ }) = i
  | orderId (Refund { orderId = i, amount = _ })  = i;

fun isPayment (Payment _) = true | isPayment _ = false;
fun isRefund  (Refund _)  = true | isRefund  _ = false;

fun paidFor id =
  List.foldl (op +) 0.0
    (from e in events where isPayment e andalso orderId e = id yield amount e);

fun refundedFor id =
  List.foldl (op +) 0.0
    (from e in events where isRefund e andalso orderId e = id yield amount e);

val ids = from e in events group orderId e;

from id in ids
  yield { id, paid = paidFor id, refunded = refundedFor id,
          ok = refundedFor id <= paidFor id };
val it =
  [{id=1,ok=true,paid=222.0,refunded=0.0},
   {id=2,ok=true,paid=500.0,refunded=100.0},
   {id=3,ok=true,paid=90.0,refunded=0.0}]
  : {id:int, ok:bool, paid:real, refunded:real} list

This is what testing looks like in Morel. Queries and assertions share an evaluator. No separate mocks, no "run the test harness." Write the invariant, render it as a table, read the ok column.

See also