If you come from domain-driven design
You already have the vocabulary. The mapping:
| you say | Vishy says |
|---|---|
| aggregate, aggregate root | unit with its state |
| entity, value object | row; a row inside a row |
| command, and the invariant it must keep | contract, its outcomes, and the unit’s invariant |
| the command’s failure | fail Outcome |
| application service, transaction | flow (atomic across units) |
| read model | view |
| domain event, outbox | emit, events, deliver |
| schema migration | version and migrate |
| the aggregate boundary, by convention | the aggregate boundary, enforced by the compiler |
The surprise is the last row. A contract that reads another unit does not compile; there is no repository to reach through and no convention to keep. A fact one aggregate needs from another is carried by the flow as a value. The chapter on flows shows the shape; “What Vishy is not” shows the refusal.
The first program you would write, an order that is placed, paid and shipped, with its lifecycle as a machine:
enum Status { placed, paid, shipped }
row Order { id: id, customer: id, total: money(2), status: Status }
unit Orders {
state orders: Order[16];
machine orders.status { start Status.placed; Status.placed -> Status.paid; Status.paid -> Status.shipped; }
invariant all(o in orders: o.total > 0);
contract place(order: id, customer: id, total: money(2)) {
case total <= 0 => fail EmptyOrder;
else => Placed: orders += [Order { id: order, customer: customer, total: total, status: Status.placed }];
examples {
{} (1, 7, 2500) => Placed { orders: [Order { id: 1, customer: 7, total: 2500, status: Status.placed }] };
{} (1, 7, 0) => EmptyOrder;
}
}
contract pay(order: id, amount: money(2)) {
case amount != orders[order].total => fail WrongAmount;
else => Paid: orders[order].status := Status.paid;
examples {
{ orders: [Order { id: 1, customer: 7, total: 2500, status: Status.placed }] } (1, 2500) => Paid { orders: [Order { id: 1, customer: 7, total: 2500, status: Status.paid }] };
{ orders: [Order { id: 1, customer: 7, total: 2500, status: Status.placed }] } (1, 100) => WrongAmount;
}
}
contract ship(order: id) => Shipped: orders[order].status := Status.shipped;
}
flow place(order: id, customer: id, total: money(2)) atomic { Orders.place(order, customer, total); }
flow pay(order: id, amount: money(2)) atomic { Orders.pay(order, amount); }
flow ship(order: id) atomic { Orders.ship(order); }
scenario one_order {
place(1, 7, 2500) => Placed;
ship(1) => WrongStatus;
pay(1, 100) => WrongAmount;
pay(1, 2500) => Paid;
ship(1) => Shipped;
}
scenario one_order
place(1, 7, 2500) => Placed
ship(1) => WrongStatus
pay(1, 100) => WrongAmount
pay(1, 2500) => Paid
ship(1) => Shipped
Orders = Orders { orders: [Order { id: 1, customer: 7, total: 2500, status: shipped }] }
5 step(s), all as expected
verify: 4 examples, 5 scenario steps and 6000 sampled checks passed
Three things to notice. The lifecycle is a machine, and ship before pay fails with WrongStatus without a guard being written. The invariant total > 0 is kept by place refusing EmptyOrder, and the verifier would find the state that breaks it if that case were missing. And the examples on each command are the acceptance tests, in the command.