goer Download ↓

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.

order→ reserveinventory→ authorizepayment→ shipfulfillment
Order process

Tracks one order and moves it through stock, payment, and delivery.

Inventory process

Owns the stock counts. Can be restarted without losing any orders.

Ports

Payment, warehouse, delivery, and notifications. Regular Go code.

Supervisor

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:

terminal
goer run ./examples/orderdesk

Here’s a recording of the first nine seconds:

goer run ./examples/orderdesk
  1. goer run ./examples/orderdesk
  2. payment port: order order-1001 approved=true
  3. notification port: order order-1001: order confirmed
  4. fulfillment port: order order-1001 queued for coffee-beans × 1
  5. warehouse check started; inspect the parked orders process in the dashboard
  6. [supervisor] "inventory" crashed: imgo panic: inventory restart requested
  7. [supervisor] restarting "inventory" (restart #1)
  8. inventory crash requested; supervised restarts: 1
  9. live pricing update applied at a safe point
  10. payment port: order order-1002 approved=true
  11. notification port: order order-1002: order confirmed with live pricing
  12. fulfillment port: order order-1002 queued for tea × 1
  13. notification port: order order-1001: warehouse check complete (12 available)
  14. order state migrated: added live price without stopping orders
  15. order intake: received order-1003
  16. payment port: order order-1003 approved=true
  17. notification port: order order-1003: order confirmed
  18. fulfillment port: order order-1003 queued for coffee-beans × 1
  19. ^C
  20. goer: shutting down
A recording of a real run, 10 seconds long. We’ve trimmed the long pauses.

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.

orderdesk_actor.go · messages
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.

orderdesk_actor.go · the order process
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:

orderdesk.go · wiring
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.

orderdesk_actor.go · the inventory process
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.

orderdesk.go · ports
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.

old price rules→safe point→new price rules

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.

00sStartOrder 1001 reserves stock, gets paid for, and goes to delivery.
0.75sWaitA slow warehouse check leaves the order process waiting in Ask for five seconds.
02sCrashInventory panics. The supervisor starts it again, fresh.
04sHot swapThe order code changes between messages, then takes order 1002.
06sMigratePriceCents is added without losing the live order.
every 8sKeep goingA new order arrives. Inventory restocks every 12 seconds.

7. Watch it run

Run the same app with the dashboard on and open the address it prints:

terminal
goer debug ./examples/orderdesk

Start 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.

Keep the dashboard to yourself.It can pause and kill processes, so it isn’t something to put on the open internet. Out of the box it only answers your own machine. If you need it from somewhere else, set a token first.

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 found

The 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 Main

Goer couldn’t find the function it looks for. Add func Main(system *goer.System) error, or use a normal package main.

imgo compile error

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.

address already in use

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:

Run

goer run ./examples/orderdesk starts the app, and Ctrl-C stops it cleanly.

Recover

You can point at the inventory process and say why its crash doesn’t wipe out the order.

Change

You can find the safe point, the code-only swap, and the later data migration.

Look inside

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
examples/orderdesk/orderdesk.go
// 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
}
examples/orderdesk/orderdesk_actor.go
//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
}
examples/orderdesk/orderdesk_v2_actor.go
//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
}
examples/orderdesk/orderdesk_v3_actor.go
//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.