The workshop
The third example. The bank runs in the browser, for one person. The workshop
runs the framework on a server, for a room: participants join from their phones, and a
control room on the projector shows what the system does while they use it. Its code
is in examples/workshop.
What the participants do
Section titled “What the participants do”- They join with a nickname, which is personal data: the framework encrypts it with a key of their own.
- They add money to their account and send it to each other. A transfer touches two accounts, so a saga completes it, and refunds the sender when the other account has closed.
- They buy from a shared catalog with real stock, paying from their account. When a product is sold out they can wait in its queue, and cancel for a refund until the goods arrive.
- They close their account: the balance is paid out, and the key of their nickname is
destroyed. The events stay, and the nickname reads
[forgotten].
A bot restocks the shop, and simulated shoppers keep it busy when the room is quiet. Both go through the same aggregate services as the HTTP API and sign their events.
Two orders, one piece
Section titled “Two orders, one piece”The queue lives in the product aggregate. Restocking hands goods to the waiting orders in the same command, in order of arrival, so two orders can’t get the same piece, and while a queue exists newcomers join it rather than take goods ahead of it:
/** * Hands the available goods to the queue in order. The first order that doesn't * fit stops it: a small order behind it doesn't jump the queue. */const allocate = (available: number, queue: ReadonlyArray<Waiting>): ReadonlyArray<ProductEvent> => { const events: Array<ProductEvent> = [] for (const waiting of queue) { if (waiting.quantity > available) break available -= waiting.quantity events.push(ProductEvent.cases.BackorderAllocated.make(waiting)) } return events}An order touches the account, the product and itself, so a saga carries it. Each step does nothing the second time, so the saga can run again after a restart. The presenter can make the next order break after it is paid, and the room watches the saga give the money back:
const CheckoutWorkflow = Saga.toLayer( Checkout, ({ backorder, customer, orderId, productId, quantity, total }) => Effect.gen(function*() { const accounts = yield* Accounts const products = yield* Products const orders = yield* Orders const chaos = yield* Chaos
const reject = (reason: string) => Saga.command("reject", { execute: orders.commands.reject(orderId, { reason }) }) const refund = (reason: string) => Saga.command("refund", { execute: accounts.commands.refundOrder(customer, { orderId, amount: total, reason }) })
const paid = yield* Saga.command("pay", { error: Schema.Union([InsufficientFunds, AccountNotOpen]), execute: accounts.commands.payOrder(customer, { orderId, amount: total }) }).pipe( Effect.as(true), Effect.catch((error) => reject(error._tag === "InsufficientFunds" ? reasons.insufficientFunds : reasons.accountClosed).pipe( Effect.as(false) ) ) ) if (!paid) return yield* Saga.command("mark-paid", { execute: orders.commands.markPaid(orderId, {}) })
// An activity, so a saga that runs again after a restart decides the same way. const broken = yield* Activity.make({ name: "warehouse", success: Schema.Boolean, execute: chaos.consume }) if (broken) { yield* refund(reasons.warehouse) return yield* reject(reasons.warehouse) }
const taken = yield* Saga.command("take", { error: OutOfStock, execute: products.commands.take(productId, { orderId, quantity }) }).pipe(Effect.as(true), Effect.catchTag("OutOfStock", () => Effect.succeed(false))) if (taken) { yield* Saga.command("fulfil", { execute: orders.commands.fulfil(orderId, {}) }) } else if (backorder) { yield* Saga.command("enqueue", { execute: products.commands.enqueue(productId, { orderId, quantity }) }) // If goods were there already, the order is fulfilled by now and this does nothing. yield* Saga.command("mark-backordered", { execute: orders.commands.markBackordered(orderId, {}) }) } else { yield* refund(reasons.outOfStock) yield* reject(reasons.outOfStock) } }))Reading your own writes
Section titled “Reading your own writes”The screens read view models, which run a moment behind the commands. Every command the
UI sends answers with the position of its last event, and the reads that follow it, the
movements, the orders, the participants and the catalog, send it back in a Read-After
header. CommandApi.ReadAfter holds
each of those reads until the view models have handled that position, so a participant
who buys something sees the order at once, while the other phones catch up on their own a
moment later.
When the view models are more than five seconds behind, because the room is busy or a
handler keeps failing, the server answers 503 with NotCaughtUp. The UI then reads
again without the header, and shows a screen that is a little old rather than an error.
The control room
Section titled “The control room”The server follows the log once and sends each event on two Server-Sent Events streams. The participants’ stream hides personal fields; the presenter’s stream shows them decrypted, next to the payload as the store keeps it, encrypted. Each event carries who caused it and the correlation of the request it follows from, so a saga’s steps line up behind the click that started them.
The control room also checks that the money adds up. The participants’ balances come from one view model, the books from another, both folded from the same events. When the two are at the same position their totals must agree, and while one is behind the room says so instead of guessing: eventual consistency on the projector.
Everything the streams send is described in an AsyncAPI 3 document, built from the
aggregates with EventCatalog.document, and the REST API in OpenAPI, built from the
same HttpApi the UI calls through a typed client. See the
source and
its README.