E-commerce Example

E-commerce Example

This example demonstrates type-safe workflows using Go structs with validation.

What This Example Shows

  • ✅ Struct-based parameters with compile-time type safety
  • ✅ Nested structs (Customer, Address, OrderItem)
  • ✅ Struct-based return values
  • ✅ Type restoration during workflow execution
  • ✅ JSON storage of structs

Code Overview

Define Structs

package main

import (
	"fmt"
	"regexp"
	"time"
)

// Address represents a customer's address
type Address struct {
	Street  string `json:"street"`
	City    string `json:"city"`
	State   string `json:"state"`
	ZipCode string `json:"zip_code"`
}

// Validate validates the address
func (a Address) Validate() error {
	zipPattern := regexp.MustCompile(`^\d{5}(-\d{4})?$`)
	if !zipPattern.MatchString(a.ZipCode) {
		return fmt.Errorf("invalid zip code: %s", a.ZipCode)
	}
	return nil
}

// Customer represents a customer
type Customer struct {
	CustomerID string   `json:"customer_id"`
	Name       string   `json:"name"`
	Email      string   `json:"email"`
	Phone      string   `json:"phone,omitempty"`
	Address    Address  `json:"address"`
}

// Validate validates the customer
func (c Customer) Validate() error {
	customerPattern := regexp.MustCompile(`^CUST-\d+$`)
	if !customerPattern.MatchString(c.CustomerID) {
		return fmt.Errorf("invalid customer ID: %s", c.CustomerID)
	}
	if c.Name == "" || len(c.Name) > 100 {
		return fmt.Errorf("name must be 1-100 characters")
	}
	emailPattern := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
	if !emailPattern.MatchString(c.Email) {
		return fmt.Errorf("invalid email: %s", c.Email)
	}
	return c.Address.Validate()
}

// OrderItem represents an item in an order
type OrderItem struct {
	ProductID string  `json:"product_id"`
	Quantity  int     `json:"quantity"`
	UnitPrice float64 `json:"unit_price"`
}

// Subtotal calculates the item subtotal
func (i OrderItem) Subtotal() float64 {
	return float64(i.Quantity) * i.UnitPrice
}

// Validate validates the order item
func (i OrderItem) Validate() error {
	productPattern := regexp.MustCompile(`^PROD-\d+$`)
	if !productPattern.MatchString(i.ProductID) {
		return fmt.Errorf("invalid product ID: %s", i.ProductID)
	}
	if i.Quantity < 1 || i.Quantity > 100 {
		return fmt.Errorf("quantity must be 1-100")
	}
	if i.UnitPrice < 0.01 {
		return fmt.Errorf("unit price must be >= 0.01")
	}
	return nil
}

// OrderInput represents the workflow input
type OrderInput struct {
	OrderID  string      `json:"order_id"`
	Customer Customer    `json:"customer"`
	Items    []OrderItem `json:"items"`
	Priority string      `json:"priority"`
	Notes    string      `json:"notes,omitempty"`
}

// Validate validates the order input
func (o OrderInput) Validate() error {
	orderPattern := regexp.MustCompile(`^ORD-\d+$`)
	if !orderPattern.MatchString(o.OrderID) {
		return fmt.Errorf("invalid order ID: %s", o.OrderID)
	}
	if err := o.Customer.Validate(); err != nil {
		return fmt.Errorf("invalid customer: %w", err)
	}
	if len(o.Items) == 0 {
		return fmt.Errorf("order must have at least one item")
	}
	for _, item := range o.Items {
		if err := item.Validate(); err != nil {
			return fmt.Errorf("invalid item: %w", err)
		}
	}
	validPriorities := map[string]bool{"low": true, "normal": true, "high": true, "urgent": true}
	if !validPriorities[o.Priority] {
		return fmt.Errorf("invalid priority: %s", o.Priority)
	}
	return nil
}

// TotalAmount calculates the total order amount
func (o OrderInput) TotalAmount() float64 {
	total := 0.0
	for _, item := range o.Items {
		total += item.Subtotal()
	}
	return total
}

// OrderResult represents the workflow result
type OrderResult struct {
	OrderID            string     `json:"order_id"`
	Status             string     `json:"status"`
	ConfirmationNumber string     `json:"confirmation_number"`
	TotalAmount        float64    `json:"total_amount"`
	EstimatedDelivery  *time.Time `json:"estimated_delivery,omitempty"`
	Message            string     `json:"message"`
}

Define Workflow

package main

import (
	"fmt"

	"github.com/i2y/romancy"
)

// processOrder is an e-commerce order processing workflow with type-safe structs.
// Romancy automatically:
// - Stores order as JSON in the database
// - Restores OrderInput type on replay
// - Returns OrderResult with proper typing
var processOrder = romancy.DefineWorkflow("process_order",
	func(ctx *romancy.WorkflowContext, order OrderInput) (OrderResult, error) {
		// Validate input
		if err := order.Validate(); err != nil {
			return OrderResult{}, fmt.Errorf("validation failed: %w", err)
		}

		// Calculate total from items
		totalAmount := order.TotalAmount()

		fmt.Printf("Processing order %s\n", order.OrderID)
		fmt.Printf("Customer: %s (%s)\n", order.Customer.Name, order.Customer.Email)
		fmt.Printf("Items: %d, Total: $%.2f\n", len(order.Items), totalAmount)

		// Workflow logic here...

		return OrderResult{
			OrderID:            order.OrderID,
			Status:             "completed",
			ConfirmationNumber: fmt.Sprintf("CONF-%s", order.OrderID),
			TotalAmount:        totalAmount,
			Message:            fmt.Sprintf("Order processed for %s", order.Customer.Name),
		}, nil
	},
)

Start the Workflow

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/i2y/romancy"
)

func main() {
	app := romancy.NewApp(
		romancy.WithDatabase("orders.db"),
		romancy.WithWorkerID("order-worker"),
	)

	ctx := context.Background()

	if err := app.Start(ctx); err != nil {
		log.Fatal(err)
	}
	defer app.Shutdown(ctx)

	// Create order with nested structs
	order := OrderInput{
		OrderID: "ORD-12345",
		Customer: Customer{
			CustomerID: "CUST-001",
			Name:       "Alice Johnson",
			Email:      "alice@example.com",
			Address: Address{
				Street:  "123 Main St",
				City:    "Springfield",
				State:   "IL",
				ZipCode: "62701",
			},
		},
		Items: []OrderItem{
			{ProductID: "PROD-101", Quantity: 2, UnitPrice: 29.99},
			{ProductID: "PROD-202", Quantity: 1, UnitPrice: 49.99},
		},
		Priority: "high",
	}

	// Start workflow - validation happens in workflow
	instanceID, err := romancy.StartWorkflow(ctx, app, processOrder, order)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Order started: %s\n", instanceID)
}

Benefits of Struct-Based Workflows

1. Compile-Time Type Safety

// This won't compile - type safety at build time
var badWorkflow = romancy.DefineWorkflow("bad",
	func(ctx *romancy.WorkflowContext, order OrderInput) (OrderResult, error) {
		// order.Customer.Name is string ✅
		// order.Items[0].Quantity is int ✅
		return OrderResult{}, nil
	},
)

2. Explicit Validation

// Validation with clear error messages
func (o OrderInput) Validate() error {
	if !orderPattern.MatchString(o.OrderID) {
		return fmt.Errorf("invalid order ID: %s", o.OrderID)
	}
	// ... more validation
	return nil
}

3. IDE Support

var processOrder = romancy.DefineWorkflow("process_order",
	func(ctx *romancy.WorkflowContext, order OrderInput) (OrderResult, error) {
		// IDE autocomplete works!
		customerName := order.Customer.Name  // ✅ Type-safe
		total := order.TotalAmount()         // ✅ Type-safe
		return OrderResult{...}, nil         // ✅ Return type checked
	},
)

4. JSON Storage with Type Restoration

Romancy stores structs as JSON and automatically restores them:

// First run: OrderInput → JSON → Database
// Replay: Database → JSON → OrderInput (automatic restoration)

Using a Validation Library

For more sophisticated validation, use a validation library like go-playground/validator:

package main

import (
	"github.com/go-playground/validator/v10"
)

// OrderInputWithTags uses struct tags for validation
type OrderInputWithTags struct {
	OrderID  string      `json:"order_id" validate:"required,startswith=ORD-"`
	Customer Customer    `json:"customer" validate:"required"`
	Items    []OrderItem `json:"items" validate:"required,min=1,dive"`
	Priority string      `json:"priority" validate:"required,oneof=low normal high urgent"`
	Notes    string      `json:"notes,omitempty"`
}

var validate = validator.New()

func (o OrderInputWithTags) Validate() error {
	return validate.Struct(o)
}

Running the Example

Create a file named ecommerce_workflow.go with the structs and workflow shown above, then run:

# Initialize Go module
go mod init ecommerce-example
go get github.com/i2y/romancy

# Run your workflow
go run ecommerce_workflow.go

Complete Code

package main

import (
	"context"
	"fmt"
	"log"
	"regexp"
	"time"

	"github.com/i2y/romancy"
)

// Address represents a customer's address
type Address struct {
	Street  string `json:"street"`
	City    string `json:"city"`
	State   string `json:"state"`
	ZipCode string `json:"zip_code"`
}

func (a Address) Validate() error {
	zipPattern := regexp.MustCompile(`^\d{5}(-\d{4})?$`)
	if !zipPattern.MatchString(a.ZipCode) {
		return fmt.Errorf("invalid zip code: %s", a.ZipCode)
	}
	return nil
}

// Customer represents a customer
type Customer struct {
	CustomerID string  `json:"customer_id"`
	Name       string  `json:"name"`
	Email      string  `json:"email"`
	Phone      string  `json:"phone,omitempty"`
	Address    Address `json:"address"`
}

func (c Customer) Validate() error {
	customerPattern := regexp.MustCompile(`^CUST-\d+$`)
	if !customerPattern.MatchString(c.CustomerID) {
		return fmt.Errorf("invalid customer ID: %s", c.CustomerID)
	}
	if c.Name == "" || len(c.Name) > 100 {
		return fmt.Errorf("name must be 1-100 characters")
	}
	emailPattern := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
	if !emailPattern.MatchString(c.Email) {
		return fmt.Errorf("invalid email: %s", c.Email)
	}
	return c.Address.Validate()
}

// OrderItem represents an item in an order
type OrderItem struct {
	ProductID string  `json:"product_id"`
	Quantity  int     `json:"quantity"`
	UnitPrice float64 `json:"unit_price"`
}

func (i OrderItem) Subtotal() float64 {
	return float64(i.Quantity) * i.UnitPrice
}

func (i OrderItem) Validate() error {
	productPattern := regexp.MustCompile(`^PROD-\d+$`)
	if !productPattern.MatchString(i.ProductID) {
		return fmt.Errorf("invalid product ID: %s", i.ProductID)
	}
	if i.Quantity < 1 || i.Quantity > 100 {
		return fmt.Errorf("quantity must be 1-100")
	}
	if i.UnitPrice < 0.01 {
		return fmt.Errorf("unit price must be >= 0.01")
	}
	return nil
}

// OrderInput represents the workflow input
type OrderInput struct {
	OrderID  string      `json:"order_id"`
	Customer Customer    `json:"customer"`
	Items    []OrderItem `json:"items"`
	Priority string      `json:"priority"`
	Notes    string      `json:"notes,omitempty"`
}

func (o OrderInput) Validate() error {
	orderPattern := regexp.MustCompile(`^ORD-\d+$`)
	if !orderPattern.MatchString(o.OrderID) {
		return fmt.Errorf("invalid order ID: %s", o.OrderID)
	}
	if err := o.Customer.Validate(); err != nil {
		return fmt.Errorf("invalid customer: %w", err)
	}
	if len(o.Items) == 0 {
		return fmt.Errorf("order must have at least one item")
	}
	for _, item := range o.Items {
		if err := item.Validate(); err != nil {
			return fmt.Errorf("invalid item: %w", err)
		}
	}
	validPriorities := map[string]bool{"low": true, "normal": true, "high": true, "urgent": true}
	if !validPriorities[o.Priority] {
		return fmt.Errorf("invalid priority: %s", o.Priority)
	}
	return nil
}

func (o OrderInput) TotalAmount() float64 {
	total := 0.0
	for _, item := range o.Items {
		total += item.Subtotal()
	}
	return total
}

// OrderResult represents the workflow result
type OrderResult struct {
	OrderID            string     `json:"order_id"`
	Status             string     `json:"status"`
	ConfirmationNumber string     `json:"confirmation_number"`
	TotalAmount        float64    `json:"total_amount"`
	EstimatedDelivery  *time.Time `json:"estimated_delivery,omitempty"`
	Message            string     `json:"message"`
}

// Workflow

var processOrder = romancy.DefineWorkflow("process_order",
	func(ctx *romancy.WorkflowContext, order OrderInput) (OrderResult, error) {
		if err := order.Validate(); err != nil {
			return OrderResult{}, fmt.Errorf("validation failed: %w", err)
		}

		totalAmount := order.TotalAmount()

		fmt.Printf("Processing order %s\n", order.OrderID)
		fmt.Printf("Customer: %s (%s)\n", order.Customer.Name, order.Customer.Email)
		fmt.Printf("Items: %d, Total: $%.2f\n", len(order.Items), totalAmount)

		return OrderResult{
			OrderID:            order.OrderID,
			Status:             "completed",
			ConfirmationNumber: fmt.Sprintf("CONF-%s", order.OrderID),
			TotalAmount:        totalAmount,
			Message:            fmt.Sprintf("Order processed for %s", order.Customer.Name),
		}, nil
	},
)

func main() {
	fmt.Println("============================================================")
	fmt.Println("Romancy Framework - E-commerce Workflow Example")
	fmt.Println("============================================================")
	fmt.Println()

	app := romancy.NewApp(
		romancy.WithDatabase("orders.db"),
		romancy.WithWorkerID("order-worker"),
	)

	ctx := context.Background()

	if err := app.Start(ctx); err != nil {
		log.Fatal(err)
	}
	defer app.Shutdown(ctx)

	order := OrderInput{
		OrderID: "ORD-12345",
		Customer: Customer{
			CustomerID: "CUST-001",
			Name:       "Alice Johnson",
			Email:      "alice@example.com",
			Address: Address{
				Street:  "123 Main St",
				City:    "Springfield",
				State:   "IL",
				ZipCode: "62701",
			},
		},
		Items: []OrderItem{
			{ProductID: "PROD-101", Quantity: 2, UnitPrice: 29.99},
			{ProductID: "PROD-202", Quantity: 1, UnitPrice: 49.99},
		},
		Priority: "high",
	}

	fmt.Println(">>> Starting order workflow...")
	fmt.Println()

	instanceID, err := romancy.StartWorkflow(ctx, app, processOrder, order)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("\n>>> Order started with instance ID: %s\n", instanceID)
}

What You Learned

  • Go Structs provide compile-time type safety
  • Nested Structs work seamlessly with Romancy
  • Explicit Validation with Validate() methods
  • Type Restoration works during replay
  • JSON Storage handles complex struct hierarchies

Next Steps