Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

If you come from domain-driven design

You already have the vocabulary. The mapping:

you sayVishy says
aggregate, aggregate rootunit with its state
entity, value objectrow; a row inside a row
command, and the invariant it must keepcontract, its outcomes, and the unit’s invariant
the command’s failurefail Outcome
application service, transactionflow (atomic across units)
read modelview
domain event, outboxemit, events, deliver
schema migrationversion and migrate
the aggregate boundary, by conventionthe 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.