Skip to main content

Shopping Cart

A stateful shopping cart example demonstrating workflows with queries, signals, updates, and cross-namespace activities.

This example showcases:

  • Stateful workflows that maintain cart state across updates
  • Queries to inspect current cart contents
  • Signals to trigger checkout
  • Updates to modify cart contents with validation
  • Cross-namespace execution with update-with-start patterns
  • Continue-as-new handling for long-running workflows
shoppingcart.proto
syntax = "proto3";

package example.shoppingcart.v1;

import "google/protobuf/empty.proto";
import "temporal/v1/temporal.proto";

service ShoppingCart {
option (temporal.v1.service) = {task_queue: "example-shoppingcart-v1"};

rpc ShoppingCart(ShoppingCartInput) returns (ShoppingCartOutput) {
option (temporal.v1.workflow) = {
name: "example.shoppingcart.v1.ShoppingCart"
id: 'example.shoppingcart.v1.ShoppingCart/${! nanoid() }'
query: {ref: "Describe"}
signal: {ref: "Checkout"}
update: {
ref: "UpdateCart"
start: true
xns: {
heartbeat_interval: {seconds: 10}
parent_close_policy: PARENT_CLOSE_POLICY_REQUEST_CANCEL
}
workflow_id_conflict_policy: WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING
}
};
}

rpc Describe(DescribeInput) returns (DescribeOutput) {
option (temporal.v1.query) = {};
}

rpc UpdateCart(UpdateCartInput) returns (UpdateCartOutput) {
option (temporal.v1.update) = {
id: 'example.shoppingcart.v1.UpdateCart/${! nanoid() }'
validate: true
};
}

rpc Checkout(CheckoutInput) returns (google.protobuf.Empty) {
option (temporal.v1.signal) = {};
}
}

message CartState {
map<string, int32> items = 1; // item_id -> quantity
}

message CheckoutInput {}

message DescribeInput {}

message DescribeOutput {
CartState cart = 1;
}

message ShoppingCartInput {
CartState cart = 1;
}

message ShoppingCartOutput {
CartState cart = 1;
}

message UpdateCartInput {
UpdateCartAction action = 1;
string item_id = 2; // item_id to add or remove
}

message UpdateCartOutput {
CartState cart = 1;
}

enum UpdateCartAction {
UPDATE_CART_ACTION_UNSPECIFIED = 0;
UPDATE_CART_ACTION_ADD = 1;
UPDATE_CART_ACTION_REMOVE = 2;
}
workflows.go
package shoppingcart

import (
shoppingcartv1 "github.com/cludden/protoc-gen-go-temporal/gen/example/shoppingcart/v1"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
"google.golang.org/protobuf/proto"
)

type (
Workflows struct{}

ShoppingCartWorkflow struct {
*Workflows
shoppingcartv1.ShoppingCartWorkflowInput
cart *shoppingcartv1.CartState
}
)

func (w *Workflows) ShoppingCart(
ctx workflow.Context,
input *shoppingcartv1.ShoppingCartWorkflowInput,
) (shoppingcartv1.ShoppingCartWorkflow, error) {
cart := input.Req.GetCart()
if cart.GetItems() == nil {
cart = &shoppingcartv1.CartState{
Items: make(map[string]int32),
}
}
return &ShoppingCartWorkflow{
Workflows: w,
ShoppingCartWorkflowInput: *input,
cart: cart,
}, nil
}

func (w *ShoppingCartWorkflow) Execute(
ctx workflow.Context,
) (*shoppingcartv1.ShoppingCartOutput, error) {
if err := workflow.Await(ctx, func() bool {
if workflow.GetInfo(ctx).GetContinueAsNewSuggested() {
return true
}
if checkout := w.Checkout.ReceiveAsync(); checkout != nil {
return true
}
return false
}); err != nil {
return nil, err
}

if workflow.GetInfo(ctx).GetContinueAsNewSuggested() {
if err := workflow.Await(ctx, func() bool {
return workflow.AllHandlersFinished(ctx)
}); err != nil {
return nil, err
}
return nil, workflow.NewContinueAsNewError(
ctx,
shoppingcartv1.ShoppingCartWorkflowName,
&shoppingcartv1.ShoppingCartInput{
Cart: w.cart,
},
)
}
return &shoppingcartv1.ShoppingCartOutput{
Cart: w.cart,
}, nil
}

func (w *ShoppingCartWorkflow) Describe(
*shoppingcartv1.DescribeInput,
) (*shoppingcartv1.DescribeOutput, error) {
return &shoppingcartv1.DescribeOutput{
Cart: w.cart,
}, nil
}

func (w *ShoppingCartWorkflow) UpdateCart(
ctx workflow.Context,
input *shoppingcartv1.UpdateCartInput,
) (*shoppingcartv1.UpdateCartOutput, error) {
switch input.GetAction() {
case shoppingcartv1.UpdateCartAction_UPDATE_CART_ACTION_ADD:
w.cart.Items[input.GetItemId()] += 1
case shoppingcartv1.UpdateCartAction_UPDATE_CART_ACTION_REMOVE:
w.cart.Items[input.GetItemId()] -= 1
if w.cart.Items[input.GetItemId()] <= 0 {
delete(w.cart.Items, input.GetItemId())
}
default:
return nil, temporal.NewNonRetryableApplicationError(
"Invalid update cart action",
"Unimplemented",
nil,
)
}
return &shoppingcartv1.UpdateCartOutput{
Cart: proto.Clone(w.cart).(*shoppingcartv1.CartState),
}, nil
}

func (w *ShoppingCartWorkflow) ValidateUpdateCart(
ctx workflow.Context,
input *shoppingcartv1.UpdateCartInput,
) error {
if input.GetAction() == shoppingcartv1.UpdateCartAction_UPDATE_CART_ACTION_UNSPECIFIED ||
input.GetItemId() == "" {
return temporal.NewNonRetryableApplicationError(
"Invalid update cart request",
"InvalidArgument",
nil,
)
}
if input.GetAction() == shoppingcartv1.UpdateCartAction_UPDATE_CART_ACTION_REMOVE &&
w.cart.GetItems()[input.GetItemId()] == 0 {
return temporal.NewNonRetryableApplicationError("Item not found in cart", "NotFound", nil)
}
return nil
}
main.go
package main

import (
"fmt"
"log"
"log/slog"
"os"
"time"

"github.com/cludden/protoc-gen-go-temporal/examples/shoppingcart"
shoppingcartv1 "github.com/cludden/protoc-gen-go-temporal/gen/example/shoppingcart/v1"
"github.com/cludden/protoc-gen-go-temporal/gen/example/shoppingcart/v1/shoppingcartv1xns"
"github.com/urfave/cli/v2"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/client"
tlog "go.temporal.io/sdk/log"
"go.temporal.io/sdk/worker"
"go.temporal.io/sdk/workflow"
"google.golang.org/protobuf/types/known/durationpb"
)

func main() {
app, err := shoppingcartv1.NewShoppingCartCli(
shoppingcartv1.NewShoppingCartCliOptions().
WithClient(newClient("shoppingcart")),
)
if err != nil {
log.Fatalf("failed to create shopping cart CLI: %v", err)
}

app.Commands = append(app.Commands, &cli.Command{
Name: "start",
Usage: "Start the shopping cart workers",
Action: start,
})
app.Flags = append(app.Flags, &cli.StringFlag{
Name: "log-level",
Aliases: []string{"l"},
Value: "debug",
Usage: "Set the logging level (debug, info, warn, error)",
})

if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

func start(cmd *cli.Context) error {
logger := newLogger(cmd)
c, err := newClient("default")(cmd)
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
defer c.Close()

// register shoppingcart namespace
_, _ = c.WorkflowService().
RegisterNamespace(cmd.Context, &workflowservice.RegisterNamespaceRequest{
Namespace: "shoppingcart",
WorkflowExecutionRetentionPeriod: durationpb.New(24 * time.Hour),
})

// create client for shoppingcart namespace
sc, err := client.NewClientFromExistingWithContext(cmd.Context, c, client.Options{
Namespace: "shoppingcart",
Logger: tlog.NewStructuredLogger(logger),
})
if err != nil {
return fmt.Errorf("failed to create shopping cart client: %w", err)
}
defer sc.Close()

// create and start default worker
dw := worker.New(c, "default", worker.Options{
MaxHeartbeatThrottleInterval: 0,
DefaultHeartbeatThrottleInterval: 0,
})
shoppingcartv1xns.RegisterShoppingCartActivities(dw, shoppingcartv1.NewShoppingCartClient(sc))

// register default workflow
dw.RegisterWorkflowWithOptions(func(ctx workflow.Context) error {
_, run, err := shoppingcartv1xns.ShoppingCartWithUpdateCart(
ctx,
&shoppingcartv1.ShoppingCartInput{},
&shoppingcartv1.UpdateCartInput{
Action: shoppingcartv1.UpdateCartAction_UPDATE_CART_ACTION_ADD,
ItemId: "foo",
},
)
if err != nil {
return err
}

workflow.Go(ctx, func(ctx workflow.Context) {
workflow.GetSignalChannel(ctx, shoppingcartv1.CheckoutSignalName).Receive(ctx, nil)
if err := run.Checkout(ctx, &shoppingcartv1.CheckoutInput{}); err != nil {
workflow.GetLogger(ctx).Error("failed to checkout", "error", err)
}
})

if _, err := run.Get(ctx); err != nil {
workflow.GetLogger(ctx).Error("failed to get shopping cart", "error", err)
}
return nil
}, workflow.RegisterOptions{Name: "default"})

if err := dw.Start(); err != nil {
return fmt.Errorf("failed to start worker: %w", err)
}
defer dw.Stop()

// create and start shoppingcart worker
sw := worker.New(sc, shoppingcartv1.ShoppingCartTaskQueue, worker.Options{})
shoppingcartv1.RegisterShoppingCartWorkflows(sw, &shoppingcart.Workflows{})
if err := sw.Start(); err != nil {
return fmt.Errorf("failed to start shopping cart worker: %w", err)
}
defer sw.Stop()

<-cmd.Context.Done()
log.Println("shutting down workers...")
return nil
}

func newClient(namespace string) func(cmd *cli.Context) (client.Client, error) {
return func(cmd *cli.Context) (client.Client, error) {
return client.DialContext(cmd.Context, client.Options{
Namespace: namespace,
Logger: tlog.NewStructuredLogger(newLogger(cmd)),
})
}
}

func newLogger(cmd *cli.Context) *slog.Logger {
var level slog.Level
switch cmd.String("log-level") {
case "error":
level = slog.LevelError
case "warn":
level = slog.LevelWarn
case "info":
level = slog.LevelInfo
default:
level = slog.LevelDebug
}
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
}))
}

Run this example​

  1. Clone the examples
    git clone https://github.com/cludden/protoc-gen-go-temporal && cd protoc-gen-go-temporal
  2. Run a local Temporal server
    temporal server start-dev
  3. Start the example workers
    go run examples/shoppingcart/cmd/shoppingcart/main.go start
  4. In a different shell, start a shopping cart workflow and add an item
    go run examples/shoppingcart/cmd/shoppingcart/main.go shopping-cart-with-update-cart \
    --req='{}' \
    --update-cart-input='{"action": "UPDATE_CART_ACTION_ADD", "itemId": "apple"}' \
    -d
  5. Query the cart contents
    go run examples/shoppingcart/cmd/shoppingcart/main.go describe \
    -w <workflow-id-from-step-4>
  6. Add more items using updates
    go run examples/shoppingcart/cmd/shoppingcart/main.go update-cart \
    -w <workflow-id-from-step-4> \
    --input='{"action": "UPDATE_CART_ACTION_ADD", "itemId": "banana"}'
  7. Checkout the cart (completes the workflow)
    go run examples/shoppingcart/cmd/shoppingcart/main.go checkout \
    -w <workflow-id-from-step-4>

Key Features Demonstrated​

Stateful Workflow​

The ShoppingCart workflow maintains cart state across its execution, persisting item quantities in a map.

Query Support​

The Describe query allows external inspection of the current cart state without affecting workflow execution.

Signal Handling​

The Checkout signal triggers workflow completion, demonstrating asynchronous communication patterns.

Update with Validation​

The UpdateCart update includes validation logic that prevents invalid operations (e.g., removing items not in the cart).

Cross-Namespace Activities​

The example demonstrates cross-namespace execution patterns using update-with-start, allowing workflows to be initiated from different namespaces.

Continue-as-New​

The workflow monitors for continue-as-new suggestions and handles them gracefully, ensuring long-running cart sessions don't hit history size limits.