Tutorial: Place an Order#
This tutorial builds a small order service from an empty Go module. It receives inventory, atomically reserves stock while placing an order, confirms and ships the order, and queries the resulting event history, status index, and customer tags.
The finished program uses the in-memory backend so it runs without infrastructure. The final step shows how to replace memory with durable storage without changing the domain code.
1. Create the Project#
mkdir timebox-orders
cd timebox-orders
go mod init example.com/timebox-orders
go get github.com/kode4food/timebox@v0.1.0Create main.go with the package and imports:
package main
import (
"errors"
"fmt"
"github.com/kode4food/timebox"
"github.com/kode4food/timebox/memory"
)2. Model the Current State#
The service has two aggregate types. A product tracks stock and an order tracks one customer’s request.
type (
// Type aliases for convenience
ProductAggregator = timebox.Aggregator[ProductState]
ProductAppliers = timebox.Appliers[ProductState]
ProductExecutor = timebox.Executor[ProductState]
OrderAggregator = timebox.Aggregator[OrderState]
OrderAppliers = timebox.Appliers[OrderState]
OrderExecutor = timebox.Executor[OrderState]
// Domain types
ProductState struct {
SKU string
Available int
Reserved int
Shipped int
}
OrderState struct {
CustomerID string
SKU string
Status string
TrackingNumber string
Quantity int
}
)These values are projections, not stored records. Timebox reconstructs them from their event histories.
3. Define the Events#
Event names and payloads are the durable history of the application. Name them for facts that happened, not commands someone attempted.
type (
// Event data types
StockChangedData struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
}
OrderPlacedData struct {
CustomerID string `json:"customer_id"`
SKU string `json:"sku"`
Quantity int `json:"quantity"`
}
OrderShippedData struct {
TrackingNumber string `json:"tracking_number"`
}
)
const (
// Event types
StockReceived timebox.EventType = "stock.received"
StockReserved timebox.EventType = "stock.reserved"
StockShipped timebox.EventType = "stock.shipped"
OrderPlaced timebox.EventType = "order.placed"
OrderConfirmed timebox.EventType = "order.confirmed"
OrderShipped timebox.EventType = "order.shipped"
// Order statuses
StatusPlaced = "placed"
StatusConfirmed = "confirmed"
StatusShipped = "shipped"
)Payloads are JSON encoded when raised. Changing an existing payload later means preserving the ability to read events already stored with the old shape.
4. Rebuild State with Appliers#
Constructors provide empty state. Appliers turn one state plus one event into the next state.
func NewProductState() ProductState {
return ProductState{}
}
func NewOrderState() OrderState {
return OrderState{}
}
func applyStockReceived(
state ProductState, _ *timebox.Event, data StockChangedData,
) ProductState {
state.SKU = data.SKU
state.Available += data.Quantity
return state
}
func applyStockReserved(
state ProductState, _ *timebox.Event, data StockChangedData,
) ProductState {
state.Available -= data.Quantity
state.Reserved += data.Quantity
return state
}
func applyStockShipped(
state ProductState, _ *timebox.Event, data StockChangedData,
) ProductState {
state.Reserved -= data.Quantity
state.Shipped += data.Quantity
return state
}
func applyOrderPlaced(
state OrderState, _ *timebox.Event, data OrderPlacedData,
) OrderState {
state.CustomerID = data.CustomerID
state.SKU = data.SKU
state.Quantity = data.Quantity
state.Status = StatusPlaced
return state
}
func applyOrderConfirmed(
state OrderState, _ *timebox.Event, _ struct{},
) OrderState {
state.Status = StatusConfirmed
return state
}
func applyOrderShipped(
state OrderState, _ *timebox.Event, data OrderShippedData,
) OrderState {
state.Status = StatusShipped
state.TrackingNumber = data.TrackingNumber
return state
}Create one executor for each state model and register its appliers under their event types. MakeApplier handles typed payload decoding.
func createProductExecutor(store *timebox.Store) *ProductExecutor {
return store.Executor(NewProductState, ProductAppliers{
StockReceived: timebox.MakeApplier(applyStockReceived),
StockReserved: timebox.MakeApplier(applyStockReserved),
StockShipped: timebox.MakeApplier(applyStockShipped),
})
}
func createOrderExecutor(store *timebox.Store) *OrderExecutor {
return store.Executor(NewOrderState, OrderAppliers{
OrderPlaced: timebox.MakeApplier(applyOrderPlaced),
OrderConfirmed: timebox.MakeApplier(applyOrderConfirmed),
OrderShipped: timebox.MakeApplier(applyOrderShipped),
})
}Appliers must be deterministic and free of side effects because they run during replay and immediately after an event is raised.
5. Add Status and Tag Indexes#
Indexes are derived in the same atomic append as their events. This indexer makes orders queryable by lifecycle status, customer, and SKU without replaying every order.
func orderIndexer(events []*timebox.Event) []*timebox.Index {
var indexes []*timebox.Index
for _, event := range events {
var status string
var tags map[string]bool
switch event.Type {
case OrderPlaced:
status = StatusPlaced
data, _ := event.GetValue[OrderPlacedData]()
tags = map[string]bool{
"customer:" + data.CustomerID: true,
"sku:" + data.SKU: true,
}
case OrderConfirmed:
status = StatusConfirmed
case OrderShipped:
status = StatusShipped
default:
continue
}
indexes = append(indexes, &timebox.Index{
Status: &status,
Tags: tags,
})
}
return indexes
}The same store also receives inventory events. The indexer ignores those event types. Tags added when an order is placed remain attached as its status changes.
6. Receive Stock#
An executor loads the product, runs the command, and appends the event only if the expected sequence still matches.
func receiveStock(
products *ProductExecutor, id timebox.AggregateID, quantity int,
) error {
_, err := products.Exec(id,
func(
_ ProductState,
ag *ProductAggregator,
) error {
return ag.Raise(StockReceived, StockChangedData{
SKU: string(id.Key),
Quantity: quantity,
})
},
)
return err
}Raise serializes the event and applies it to the current state immediately. Returning an error from the command would discard everything raised during that execution.
7. Reserve Stock and Place the Order Atomically#
Stock must not be reserved unless the order is also created. Use one transaction across both aggregates.
var (
errInsufficientStock = errors.New("insufficient stock")
errOrderExists = errors.New("order already exists")
)
type placeOrderArgs struct {
store *timebox.Store
products *ProductExecutor
orders *OrderExecutor
productID, orderID timebox.AggregateID
customerID string
quantity int
}
func placeOrder(args placeOrderArgs) error {
return args.store.Transact(func(tx *timebox.Transaction) error {
_, err := tx.Exec(args.products, args.productID,
func(
product ProductState,
ag *ProductAggregator,
) error {
if product.Available < args.quantity {
return errInsufficientStock
}
return ag.Raise(StockReserved, StockChangedData{
SKU: product.SKU,
Quantity: args.quantity,
})
},
)
if err != nil {
return err
}
_, err = tx.Exec(args.orders, args.orderID,
func(
order OrderState,
ag *OrderAggregator,
) error {
if order.Status != "" {
return errOrderExists
}
return ag.Raise(OrderPlaced, OrderPlacedData{
CustomerID: args.customerID,
SKU: string(args.productID.Key),
Quantity: args.quantity,
})
},
)
return err
})
}Every backend checks both aggregate sequences before appending either batch. A conflict reloads state and reruns the transaction, so transaction functions must not perform external side effects. Use Aggregator.OnSuccess for work that should begin only after commit.
8. Confirm the Order#
The current projection lets a command enforce domain rules before raising the next event.
func confirmOrder(
orders *OrderExecutor, id timebox.AggregateID,
) error {
_, err := orders.Exec(id,
func(order OrderState, ag *OrderAggregator) error {
if order.Status != StatusPlaced {
return fmt.Errorf(
"cannot confirm order in %q state", order.Status,
)
}
return ag.Raise(OrderConfirmed, struct{}{})
},
)
return err
}9. Ship the Order and Inventory Atomically#
Shipping changes both aggregates again: the order gains a tracking number while reserved inventory becomes shipped inventory. Keeping those changes in one transaction prevents an order from appearing shipped while its stock remains reserved.
type shipOrderArgs struct {
store *timebox.Store
products *ProductExecutor
orders *OrderExecutor
productID, orderID timebox.AggregateID
trackingNumber string
}
func shipOrder(args shipOrderArgs) error {
return args.store.Transact(func(tx *timebox.Transaction) error {
order, err := tx.Exec(args.orders, args.orderID,
func(
order OrderState,
ag *OrderAggregator,
) error {
if order.Status != StatusConfirmed {
return fmt.Errorf(
"cannot ship order in %q state", order.Status,
)
}
return ag.Raise(OrderShipped, OrderShippedData{
TrackingNumber: args.trackingNumber,
})
},
)
if err != nil {
return err
}
_, err = tx.Exec(args.products, args.productID,
func(
product ProductState,
ag *ProductAggregator,
) error {
if product.Reserved < order.Quantity {
return errInsufficientStock
}
return ag.Raise(StockShipped, StockChangedData{
SKU: product.SKU,
Quantity: order.Quantity,
})
},
)
return err
})
}The state returned by the first tx.Exec includes the newly raised shipping event, so the inventory update can use the order’s quantity. Neither aggregate commits unless both commands succeed.
10. Wire and Run the Service#
Add main and run. The memory backend provides the complete Timebox contract without an external service.
func main() {
if err := run(); err != nil {
panic(err)
}
}
func run() error {
backend := memory.Open()
defer func() { _ = backend.Close() }()
store, err := backend.NewStore(timebox.Config{Indexer: orderIndexer})
if err != nil {
return err
}
products := createProductExecutor(store)
orders := createOrderExecutor(store)
productID := timebox.NewAggregateID("product", "SKU-RED-MUG")
orderID := timebox.NewAggregateID("order", "ORD-1001")
if err := receiveStock(products, productID, 10); err != nil {
return err
}
if err := placeOrder(placeOrderArgs{
store: store,
products: products,
orders: orders,
productID: productID,
orderID: orderID,
customerID: "CUS-42",
quantity: 3,
}); err != nil {
return err
}
if err := confirmOrder(orders, orderID); err != nil {
return err
}
if err := shipOrder(shipOrderArgs{
store: store,
products: products,
orders: orders,
productID: productID,
orderID: orderID,
trackingNumber: "TRACK-9000",
}); err != nil {
return err
}Read the resulting projections and inspect the history, status index, and customer tag:
product, err := products.Get(productID)
if err != nil {
return err
}
order, err := orders.Get(orderID)
if err != nil {
return err
}
events, err := store.GetEvents(orderID, 0)
if err != nil {
return err
}
shipped, err := store.ListAggregatesByStatus("shipped")
if err != nil {
return err
}
customerOrders, err := store.ListAggregatesByTag("customer:CUS-42")
if err != nil {
return err
}
fmt.Printf("order: %s x%d (%s, %s)\n", order.SKU, order.Quantity,
order.Status, order.TrackingNumber)
fmt.Printf("stock: %d available, %d reserved, %d shipped\n",
product.Available, product.Reserved, product.Shipped)
fmt.Printf("history: %d events; shipped index: %d; customer index: %d\n",
len(events), len(shipped), len(customerOrders))
return nil
}Run it:
go run .The result is:
order: SKU-RED-MUG x3 (shipped, TRACK-9000)
stock: 7 available, 0 reserved, 3 shipped
history: 3 events; shipped index: 1; customer index: 1The complete runnable source is in examples/tutorial/main.go.
11. Use Durable Storage#
Only backend construction changes when the service needs persistence. For PostgreSQL:
backend, err := postgres.Open(postgres.Config{
URL: os.Getenv("DATABASE_URL"),
Prefix: "orders",
})
if err != nil {
return err
}
defer backend.Close()
store, err := backend.NewStore(timebox.Config{Indexer: orderIndexer})The aggregate models, appliers, commands, transactions, snapshots, and queries stay the same. See Backends for Redis/Valkey and Raft configuration.