Tutorial 05 / the big one
Put it into practice.
One idea at a time is easy. The hard part is knowing how they fit together in something real. So here’s a small order desk, the kind of thing you might actually be asked to build. It takes an order, reserves stock, charges a card, queues the delivery, and sends a confirmation. Along the way one part crashes, the pricing rules change while it’s running, and orders keep coming in. You’ll read it piece by piece, and by the end you’ll be able to say why each part is its own process: its own small worker, with data nobody else can touch.
It leaves out most of what a real shop needs, and that keeps it small enough to hold in your head.
What we’re building
An order desk does several different jobs. The order process keeps track of one order and walks it through each step. The inventory process keeps the stock counts. Payments, delivery, the warehouse lookup, and notifications are all ports, because they reach outside the program.
Why split it up like that? Because each part can fail for a different reason. Give each its own box and a failure in one doesn’t spill into the others.
Tracks one order and moves it through stock, payment, and delivery.
Owns the stock counts. Can be restarted without losing any orders.
Payment, warehouse, delivery, and notifications. Regular Go code.
Decides what to do when inventory stops unexpectedly.
1. Start it
The whole app is in the repository at examples/orderdesk. Run it from the top folder:
goer run ./examples/orderdeskHere’s a recording of the first nine seconds:
- goer run ./examples/orderdesk
- payment port: order order-1001 approved=true
- notification port: order order-1001: order confirmed
- fulfillment port: order order-1001 queued for coffee-beans × 1
- warehouse check started; inspect the parked orders process in the dashboard
- [supervisor] "inventory" crashed: imgo panic: inventory restart requested
- [supervisor] restarting "inventory" (restart #1)
- inventory crash requested; supervised restarts: 1
- live pricing update applied at a safe point
- payment port: order order-1002 approved=true
- notification port: order order-1002: order confirmed with live pricing
- fulfillment port: order order-1002 queued for tea × 1
- notification port: order order-1001: warehouse check complete (12 available)
- order state migrated: added live price without stopping orders
- order intake: received order-1003
- payment port: order order-1003 approved=true
- notification port: order order-1003: order confirmed
- fulfillment port: order order-1003 queued for coffee-beans × 1
- ^C
- goer: shutting down
The app is one regular Go file that connects everything, plus three //go:build goer files with the processes and their later versions. We’ll walk through the important bits. The full files are at the bottom of the page.
2. Give each piece of data one owner
Start with messages that say what should happen. Don’t start with functions that reach into someone else’s data. The order process sends ReserveStock. It can’t change the stock numbers itself. Inventory answers with StockReserved, sent to the address in ReplyTo.
type PlaceOrder struct {
OrderID string
SKU string
Quantity int
AmountCents int
}
type ReserveStock struct {
OrderID string
SKU string
Quantity int
ReplyTo Pid
}
type StockReserved struct {
OrderID string
SKU string
Quantity int
}That’s what “isolation” means day to day. One process owns each piece of changing data. Everyone else has to ask.
3. Write down what happens when things go right
The order process saves the order and takes it through the steps. It sends ReserveStock to inventory. When stock is confirmed, it uses Ask on the payment port, so the answer comes back to the same spot in the code. If the payment’s approved, it queues the delivery and a notification.
func Order(s OrderState, m Msg) OrderState {
switch x := m.(type) {
case PlaceOrder:
s.OrderID = x.OrderID
s.SKU = x.SKU
s.Quantity = x.Quantity
s.AmountCents = x.AmountCents
s.Status = "waiting for stock"
s.Inventory <- ReserveStock{OrderID: x.OrderID, SKU: x.SKU,
Quantity: x.Quantity, ReplyTo: Self()}
case StockReserved:
s.Status = "authorizing payment"
payment := Ask(Port("payments"), AuthorizePayment{
OrderID: s.OrderID, AmountCents: s.AmountCents}).(PaymentResult)
if payment.Approved {
s.Status = "fulfilling"
Port("fulfillment") <- FulfillOrder{OrderID: s.OrderID, SKU: s.SKU, Quantity: s.Quantity}
Port("notifications") <- Notify{OrderID: s.OrderID, Message: "order confirmed"}
} else {
s.Status = "payment declined"
Port("notifications") <- Notify{OrderID: s.OrderID, Message: "payment declined"}
}
}
return s
}On the Go side, Main starts inventory first. That way it can put inventory’s address into the order process’s starting state. Then it sends the first order:
inventory := sys.Spawn("inventory", goer.Init(inventoryInit),
goer.Behavior(inventoryBehaviour), goer.RestartOnCrash)
_, orderInit, err := imgo.Executor(GoerModule, "Order", []msg.Field{
msg.Int("Inventory", int64(inventory.Pid())),
msg.Str("Status", "idle"),
})
orders := sys.Spawn("orders", goer.Init(orderInit),
goer.Behavior(orderBehaviour), goer.RestartOnCrash)
return orders.Tell(&msg.Spec{Type: "PlaceOrder", Fields: []msg.Field{
msg.Str("OrderID", "order-1001"),
msg.Str("SKU", "coffee-beans"),
msg.Int("Quantity", 1),
msg.Int("AmountCents", 499),
}})No shared map. No lock. The third argument to imgo.Executor is a list of fields to set in the starting state, and that’s how one process learns another’s address.
4. Let a failure stay small
Two seconds in, the Go side sends inventory a CrashInventory message and inventory panics. In real life the cause might be a garbled reply from another service, or a plain bug. For practice, a message that says “crash now” does the job.
func Inventory(s InventoryState, m Msg) InventoryState {
switch x := m.(type) {
case ReserveStock:
if s.Stock < x.Quantity {
panic("not enough stock")
}
s.Stock = s.Stock - x.Quantity
x.ReplyTo <- StockReserved{OrderID: x.OrderID, SKU: x.SKU, Quantity: x.Quantity}
case CrashInventory:
panic("inventory restart requested")
case Restock:
s.Stock = s.Stock + x.Quantity
}
return s
}Inventory was started with goer.RestartOnCrash. So the supervisor throws away its broken state, runs InventoryInit again, and starts it fresh. The order process isn’t touched.
That’s the payoff of small boxes. You fix the part that broke, and you don’t have to treat everything as broken.
5. Put the outside world behind ports
Sending a confirmation might mean calling an email service, hitting a webhook, or writing to a log. None of that belongs in the order process. The Go side registers four ports. The warehouse one sleeps for five seconds on purpose, so you get a chance to see a waiting process in the dashboard.
sys.RegisterPort("payments", func(req msg.Spec) msg.Spec {
amount, _ := req.Get("AmountCents")
return msg.Spec{Type: "PaymentResult", Fields: []msg.Field{
msg.Bool("Approved", amount.I <= 5000),
msg.Str("AuthCode", "auth-demo"),
}}
})
sys.RegisterPort("warehouse", func(req msg.Spec) msg.Spec {
time.Sleep(5 * time.Second) // a slow remote service
sku, _ := req.Get("SKU")
return msg.Spec{Type: "InventoryResult", Fields: []msg.Field{
msg.Str("SKU", sku.S),
msg.Int("Available", 12),
}}
})In a test you’d swap these for fakes that write down what they were asked. Then you can test the order process without a payment account or a network.
6. Change the pricing without stopping orders
At four seconds the Go side installs OrderV2 with HotSwap. Same data, different wording, so the next confirmation says “with live pricing”. At six seconds it calls Migrate with OrderV3, which adds a PriceCents field, and MigrateOrder, which copies the live order into the new shape. Messages that were already waiting keep their place.
One honest caveat. Changing code live doesn’t make every change safe. The new code has to understand the old data, and when it doesn’t, you have to write the migration.
7. Watch it run
Run the same app with the dashboard on and open the address it prints:
goer debug ./examples/orderdeskStart with the Actors tab. orders and inventory are two separate processes with separate lives. Within the first second orders shows something like Order @51 · orderdesk_actor.go:109. That’s it waiting inside Ask for the slow warehouse. Click its name and you’ll see the messages piling up behind it.
After the crash, the Supervision tab shows inventory with one restart, and Crashes tells you why, which message it was handling, and the file and line. Try pausing orders, watch its mailbox fill up with PlaceOrder, then step through one message at a time. Press Ctrl-C in the terminal when you’re done.
What about more than one machine?
Once the order desk makes sense on one machine, spreading it across several is a deployment decision. You don’t redesign anything. Inventory could run on one node and orders on another. A message still names a process, and Goer works out where to send it and tells you if the other machine isn’t there.
Our advice is to start on one machine. Learn where the failures are before you add the network’s failures on top. The architecture page shows how to connect two nodes.
Putting a process on another node
When you do spread out, the code barely changes. One node listens, the other connects. You can start a registered template on a named node with sys.SpawnOn("orders-b", "inventory"), and sys.SuperviseOn(...) keeps the restart rule at home while the process runs over there. (The end of tutorial 02 shows that call.) Messages, polite stops, and monitors all work across the network the same way they do locally.
The fun one is the WebAssembly node. You can build all of Goer for GOOS=wasip1 and have it join the cluster as a full member. It has no sockets, so it can’t listen for connections. It talks over its own stdin and stdout, and whatever program started it passes those bytes along. cmd/wasmnode shows how: the WebAssembly side calls sys.Attach("wasm", runtime.NewStdioStream(os.Stdin, os.Stdout)).
It can do everything a regular node can: receive messages, answer questions about a process (QueryField), start registered templates, run process code that was shipped to it, be monitored, be stopped politely, and shut down cleanly when its stdin closes.
It does have limits, and they all come from WebAssembly. It’s single-threaded. It’s interpreted only. It has no sockets. And there’s no reconnect: if it dies, everything watching it gets "node unreachable", exactly like a dropped network connection. The distribution page has the full story.
If it doesn’t work first time
goer: command not foundThe tool isn’t on your PATH. Install the release, or build cmd/goer from the repository, and run again from the top folder.
no func MainGoer couldn’t find the function it looks for. Add func Main(system *goer.System) error, or use a normal package main.
Process code gets checked when goer run builds the package. Start with the file and line it reports. Plain go build doesn’t look at //go:build goer files, but goer test does.
Something else is on port 8787, probably another dashboard. Stop it, or pick a different port with -addr 127.0.0.1:8788.
Did it sink in?
You’re done when you can tick these off without peeking at the code:
goer run ./examples/orderdesk starts the app, and Ctrl-C stops it cleanly.
You can point at the inventory process and say why its crash doesn’t wipe out the order.
You can find the safe point, the code-only swap, and the later data migration.
goer debug shows you the processes, the one that’s waiting, the restart, and the crash report.
The complete source
These are the files exactly as they are in the repository.
Show examples/orderdesk
// Package orderdesk is the runnable companion to the "Putting it into
// practice" tutorial.
//
// Run it from the module root with:
//
// goer run ./examples/orderdesk
//
// It models a small order lifecycle: an order actor reserves stock, authorizes
// payment, asks fulfillment to ship the order, and sends a notification. The
// deliberately slow warehouse port gives the dashboard a real parked VM
// continuation to inspect. The inventory actor is supervised and is crashed
// once on purpose so its restart is visible in the output.
package orderdesk
import (
"fmt"
"time"
"goer"
"goer/imgo"
"goer/msg"
)
// Main wires the example into the runtime. The CLI owns the outer lifecycle;
// this function only creates the processes and their native effect boundary.
func Main(sys *goer.System) error {
orderBehaviour, _, err := imgo.Executor(GoerModule, "Order", []msg.Field{
msg.Str("Status", "idle"),
})
if err != nil {
return err
}
inventoryBehaviour, inventoryInit, err := imgo.Executor(GoerModule, "Inventory", nil)
if err != nil {
return err
}
v2Behaviour, _, err := imgo.Executor(GoerModule, "OrderV2", nil)
if err != nil {
return err
}
migrateOrder, err := imgo.Migration(GoerModule, "MigrateOrder")
if err != nil {
return err
}
sys.RegisterPort("notifications", func(req msg.Spec) msg.Spec {
of, hasAvail := req.Get("Available")
orderID, _ := req.Get("OrderID")
mf, _ := req.Get("Message")
if hasAvail {
fmt.Printf("notification port: order %v: %v (%v available)\n", orderID.S, mf.S, of.I)
} else {
fmt.Printf("notification port: order %v: %v\n", orderID.S, mf.S)
}
return msg.Spec{Type: "Notified"}
})
sys.RegisterPort("payments", func(req msg.Spec) msg.Spec {
af, _ := req.Get("AmountCents")
of, _ := req.Get("OrderID")
approved := af.I <= 5000
fmt.Printf("payment port: order %v approved=%t\n", of.S, approved)
return msg.Spec{Type: "PaymentResult", Fields: []msg.Field{
msg.Bool("Approved", approved),
msg.Str("AuthCode", "auth-demo"),
}}
})
sys.RegisterPort("fulfillment", func(req msg.Spec) msg.Spec {
of, _ := req.Get("OrderID")
sf, _ := req.Get("SKU")
qf, _ := req.Get("Quantity")
fmt.Printf("fulfillment port: order %v queued for %v × %v\n", of.S, sf.S, qf.I)
return msg.Spec{Type: "FulfillmentQueued"}
})
sys.RegisterPort("warehouse", func(req msg.Spec) msg.Spec {
// This simulates a remote inventory service. While it waits, the order
// process is parked in Ask and its VM continuation is inspectable.
time.Sleep(5 * time.Second)
sf, _ := req.Get("SKU")
return msg.Spec{Type: "InventoryResult", Fields: []msg.Field{
msg.Str("SKU", sf.S),
msg.Int("Available", 12),
}}
})
inventory := sys.Spawn(
"inventory",
goer.Init(inventoryInit),
goer.Behavior(inventoryBehaviour),
goer.RestartOnCrash,
)
v3Behaviour, v3Init, err := imgo.Executor(GoerModule, "OrderV3", []msg.Field{
msg.Int("Inventory", int64(inventory.Pid())),
msg.Str("Status", "idle"),
msg.Int("PriceCents", 499),
})
if err != nil {
return err
}
_, orderInit, err := imgo.Executor(GoerModule, "Order", []msg.Field{
msg.Int("Inventory", int64(inventory.Pid())),
msg.Str("Status", "idle"),
})
if err != nil {
return err
}
orders := sys.Spawn(
"orders",
goer.Init(orderInit),
goer.Behavior(orderBehaviour),
goer.RestartOnCrash,
)
if err := orders.Tell(&msg.Spec{Type: "PlaceOrder", Fields: []msg.Field{
msg.Str("OrderID", "order-1001"),
msg.Str("SKU", "coffee-beans"),
msg.Int("Quantity", 1),
msg.Int("AmountCents", 499),
}}); err != nil {
return err
}
go func() {
timer := time.NewTimer(750 * time.Millisecond)
defer timer.Stop()
<-timer.C
_ = orders.Tell(&msg.Spec{Type: "InspectInventory", Fields: []msg.Field{
msg.Str("OrderID", "order-1001"),
msg.Str("SKU", "coffee-beans"),
}})
fmt.Println("warehouse check started; inspect the parked orders process in the dashboard")
}()
go func() {
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
<-timer.C
_ = inventory.Tell(&msg.Spec{Type: "CrashInventory"})
deadline := time.Now().Add(time.Second)
for sys.Restarts() == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
fmt.Printf("inventory crash requested; supervised restarts: %d\n", sys.Restarts())
}()
go func() {
timer := time.NewTimer(4 * time.Second)
defer timer.Stop()
<-timer.C
if err := sys.HotSwap("orders", goer.Behavior(v2Behaviour)); err != nil {
fmt.Printf("live pricing update failed: %v\n", err)
return
}
fmt.Println("live pricing update applied at a safe point")
_ = orders.Tell(&msg.Spec{Type: "PlaceOrder", Fields: []msg.Field{
msg.Str("OrderID", "order-1002"),
msg.Str("SKU", "tea"),
msg.Int("Quantity", 1),
msg.Int("AmountCents", 799),
}})
}()
go func() {
timer := time.NewTimer(6 * time.Second)
defer timer.Stop()
<-timer.C
if err := sys.Migrate(
"orders",
goer.Behavior(v3Behaviour),
goer.Init(v3Init),
goer.Migrate(migrateOrder),
); err != nil {
fmt.Printf("state migration failed: %v\n", err)
return
}
fmt.Println("order state migrated: added live price without stopping orders")
}()
// Keep the dashboard useful after the scripted demonstrations finish. This
// is deliberately slow: one new order every eight seconds is enough to
// watch mailboxes, handler timing, ports, and supervision without flooding
// a terminal or hiding the important events.
go func() {
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
sequence := 1003
for range ticker.C {
orderID := fmt.Sprintf("order-%d", sequence)
if err := orders.Tell(&msg.Spec{Type: "PlaceOrder", Fields: []msg.Field{
msg.Str("OrderID", orderID),
msg.Str("SKU", "coffee-beans"),
msg.Int("Quantity", 1),
msg.Int("AmountCents", 499),
}}); err != nil {
fmt.Printf("order intake: %s rejected: %v\n", orderID, err)
continue
}
fmt.Printf("order intake: received %s\n", orderID)
sequence++
}
}()
go func() {
ticker := time.NewTicker(12 * time.Second)
defer ticker.Stop()
for range ticker.C {
_ = inventory.Tell(&msg.Spec{Type: "Restock", Fields: []msg.Field{
msg.Int("Quantity", 4),
}})
fmt.Println("inventory intake: restocked 4 units")
}
}()
return nil
}//go:build goer && goer_source
package orderdesk
import . "goer"
type OrderState struct {
Inventory Pid
Status string
OrderID string
SKU string
Quantity int
AmountCents int
}
type InventoryState struct {
Stock int
}
type PlaceOrder struct {
OrderID string
SKU string
Quantity int
AmountCents int
}
type ReserveStock struct {
OrderID string
SKU string
Quantity int
ReplyTo Pid
}
type StockReserved struct {
OrderID string
SKU string
Quantity int
}
type AuthorizePayment struct {
OrderID string
AmountCents int
}
type PaymentResult struct {
Approved bool
AuthCode string
}
type FulfillOrder struct {
OrderID string
SKU string
Quantity int
}
type InspectInventory struct {
OrderID string
SKU string
}
type InventoryRequest struct {
SKU string
}
type InventoryResult struct {
SKU string
Available int
}
type CrashInventory struct{}
type Restock struct {
Quantity int
}
type Notify struct {
OrderID string
Message string
Available int
}
type Msg interface{}
func OrderInit() OrderState { return OrderState{Status: "idle"} }
func InventoryInit() InventoryState { return InventoryState{Stock: 4} }
func Order(s OrderState, m Msg) OrderState {
switch x := m.(type) {
case PlaceOrder:
s.OrderID = x.OrderID
s.SKU = x.SKU
s.Quantity = x.Quantity
s.AmountCents = x.AmountCents
s.Status = "waiting for stock"
s.Inventory <- ReserveStock{OrderID: x.OrderID, SKU: x.SKU, Quantity: x.Quantity, ReplyTo: Self()}
case StockReserved:
s.Status = "authorizing payment"
payment := Ask(Port("payments"), AuthorizePayment{OrderID: s.OrderID, AmountCents: s.AmountCents}).(PaymentResult)
if payment.Approved {
s.Status = "fulfilling"
Port("fulfillment") <- FulfillOrder{OrderID: s.OrderID, SKU: s.SKU, Quantity: s.Quantity}
Port("notifications") <- Notify{OrderID: s.OrderID, Message: "order confirmed"}
} else {
s.Status = "payment declined"
Port("notifications") <- Notify{OrderID: s.OrderID, Message: "payment declined"}
}
case InspectInventory:
result := Ask(Port("warehouse"), InventoryRequest{SKU: x.SKU}).(InventoryResult)
s.OrderID = x.OrderID
if result.Available > 0 {
s.Status = "warehouse checked"
} else {
s.Status = "out of stock"
}
Port("notifications") <- Notify{OrderID: x.OrderID, Message: "warehouse check complete", Available: result.Available}
}
return s
}
func Inventory(s InventoryState, m Msg) InventoryState {
switch x := m.(type) {
case ReserveStock:
if s.Stock < x.Quantity {
panic("not enough stock")
}
s.Stock = s.Stock - x.Quantity
x.ReplyTo <- StockReserved{OrderID: x.OrderID, SKU: x.SKU, Quantity: x.Quantity}
case CrashInventory:
panic("inventory restart requested")
case Restock:
s.Stock = s.Stock + x.Quantity
}
return s
}//go:build goer && goer_source
package orderdesk
func OrderV2Init() OrderState { return OrderState{Status: "idle"} }
func OrderV2(s OrderState, m Msg) OrderState {
switch x := m.(type) {
case PlaceOrder:
s.OrderID = x.OrderID
s.SKU = x.SKU
s.Quantity = x.Quantity
s.AmountCents = x.AmountCents
s.Status = "waiting for stock"
s.Inventory <- ReserveStock{OrderID: x.OrderID, SKU: x.SKU, Quantity: x.Quantity, ReplyTo: Self()}
case StockReserved:
s.Status = "authorizing payment"
payment := Ask(Port("payments"), AuthorizePayment{OrderID: s.OrderID, AmountCents: s.AmountCents}).(PaymentResult)
if payment.Approved {
s.Status = "fulfilling"
Port("fulfillment") <- FulfillOrder{OrderID: s.OrderID, SKU: s.SKU, Quantity: s.Quantity}
Port("notifications") <- Notify{OrderID: s.OrderID, Message: "order confirmed with live pricing"}
}
}
return s
}//go:build goer && goer_source
package orderdesk
type OrderStateV3 struct {
Inventory Pid
Status string
OrderID string
SKU string
Quantity int
AmountCents int
PriceCents int
}
func OrderV3Init() OrderStateV3 { return OrderStateV3{Status: "idle", PriceCents: 499} }
func MigrateOrder(old OrderState) OrderStateV3 {
return OrderStateV3{Inventory: old.Inventory, Status: old.Status, OrderID: old.OrderID, SKU: old.SKU, Quantity: old.Quantity, AmountCents: old.AmountCents, PriceCents: 499}
}
func OrderV3(s OrderStateV3, m Msg) OrderStateV3 {
switch x := m.(type) {
case PlaceOrder:
s.OrderID = x.OrderID
s.SKU = x.SKU
s.Quantity = x.Quantity
s.AmountCents = x.AmountCents
s.Status = "waiting for stock"
s.Inventory <- ReserveStock{OrderID: x.OrderID, SKU: x.SKU, Quantity: x.Quantity, ReplyTo: Self()}
case StockReserved:
s.Status = "authorizing payment"
payment := Ask(Port("payments"), AuthorizePayment{OrderID: s.OrderID, AmountCents: s.AmountCents}).(PaymentResult)
if payment.Approved {
s.Status = "fulfilling"
Port("fulfillment") <- FulfillOrder{OrderID: s.OrderID, SKU: s.SKU, Quantity: s.Quantity}
Port("notifications") <- Notify{OrderID: s.OrderID, Message: "order confirmed"}
}
}
return s
}There’s one more file in the package, orderdesk_module_fallback.go. It compiles the process files at startup when plain go test builds the package. Everything the command-line tool does is written up in the CLI handoff.
What to take away
A good Goer system is a handful of small owners that talk by message, with a supervisor around the parts that might fail and ports around anything that touches the outside world.
The tool gets it running. Hot code keeps changes small. And the dashboard shows you whether it behaves the way you meant it to.