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="order",total=1152},{kind="payment",total=812},
{kind="refund",total=100}] : {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 a
constructor with its own payload, and the compiler enforces
exhaustiveness when you pattern-match. Add a fourth event kind and
every fun matching event must gain a clause for it. The shape is
the schema, and the schema is the code.
Two helpers — kind and amount — turn the typed events back into
uniform fields the 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 reads like SQL, the type
definition reads like Haskell, and they share a file.
Small gotcha, still there in 0.9. Partial record patterns inside a
constructor payload — Order { id, ... } — hit the same bug as recipe
02. Bind every field with =, using a wildcard where you don't care
(id = _, amount = a).
Variations
The business invariant as a query. Per order ID, sum payments and
refunds and assert one doesn't exceed the other. The ok column is
the test — any false is 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,refunded=0},{id=2,ok=true,paid=500,refunded=100},
{id=3,ok=true,paid=90,refunded=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 mocks, no separate harness. Write the invariant,
render it as a table, read the ok column.
See also
- Recipe 12 — Derive columns with pattern matching — the
caseand function-clause patterns used throughout. - Recipe 13 — Handle missing values —
optionis the smallest ADT; everything on this page is a bigger version. - Recipe 19 — Compose queries from small pieces —
paidForandrefundedForare the pattern that recipe makes explicit.