Your First Workflow
In this tutorial, you’ll build a complete order processing workflow with compensation (Saga pattern). This workflow demonstrates:
- ✅ Type-safe inputs/outputs with Go structs
- ✅ Automatic compensation on failure
- ✅ Durable execution with crash recovery
- ✅ Event publishing with transactional outbox
Prerequisites
Before starting, make sure you have Romancy installed:
go get github.com/i2y/romancyIf you haven’t set up your Go environment, see the Installation Guide.
What We’re Building
An e-commerce order processing system that:
- Reserves inventory for ordered items
- Processes payment for the order
- Ships the order to the customer
- Publishes events at each step
- Automatically rolls back if any step fails
Step 1: Define Data Structures
Create main.go and start with data structures:
package main
import (
"context"
"fmt"
"log"
"regexp"
"github.com/i2y/romancy"
)
// OrderItem represents a single item in an order
type OrderItem struct {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"` // At least 1
UnitPrice float64 `json:"unit_price"` // Positive price
}
// ShippingAddress represents customer shipping address
type ShippingAddress struct {
Street string `json:"street"`
City string `json:"city"`
PostalCode string `json:"postal_code"`
Country string `json:"country"`
}
// OrderInput is the input for order processing workflow
type OrderInput struct {
OrderID string `json:"order_id"` // e.g., ORD-123
CustomerEmail string `json:"customer_email"`
Items []OrderItem `json:"items"`
ShippingAddress ShippingAddress `json:"shipping_address"`
}
// OrderResult is the result of order processing
type OrderResult struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
TotalAmount float64 `json:"total_amount"`
ConfirmationNumber string `json:"confirmation_number"`
}Step 2: Create Activities
Add the three main activities with compensation:
// Compensation functions
var cancelInventoryReservation = romancy.DefineCompensation("cancel_inventory_reservation",
func(ctx context.Context, orderID string) error {
fmt.Printf("❌ Cancelling inventory reservation for %s\n", orderID)
return nil
},
)
var refundPayment = romancy.DefineCompensation("refund_payment",
func(ctx context.Context, orderID string, amount float64) error {
fmt.Printf("❌ Refunding payment for %s: $%.2f\n", orderID, amount)
return nil
},
)
// Activity result types
type ReservationResult struct {
ReservationID string `json:"reservation_id"`
TotalAmount float64 `json:"total_amount"`
}
type PaymentResult struct {
TransactionID string `json:"transaction_id"`
Amount float64 `json:"amount"`
Status string `json:"status"`
}
type ShipmentResult struct {
TrackingNumber string `json:"tracking_number"`
Status string `json:"status"`
}
// Activities with compensation links
var reserveInventory = romancy.DefineActivity("reserve_inventory",
func(ctx context.Context, orderID string, items []OrderItem) (ReservationResult, error) {
var total float64
for _, item := range items {
total += float64(item.Quantity) * item.UnitPrice
}
fmt.Printf("📦 Reserving inventory for %s: $%.2f\n", orderID, total)
return ReservationResult{
ReservationID: fmt.Sprintf("RES-%s", orderID),
TotalAmount: total,
}, nil
},
romancy.WithCompensation(cancelInventoryReservation),
)
var processPayment = romancy.DefineActivity("process_payment",
func(ctx context.Context, orderID string, amount float64, customerEmail string) (PaymentResult, error) {
fmt.Printf("💳 Processing payment for %s: $%.2f\n", orderID, amount)
return PaymentResult{
TransactionID: fmt.Sprintf("TXN-%s", orderID),
Amount: amount,
Status: "completed",
}, nil
},
romancy.WithCompensation(refundPayment),
)
// Activity 3: Ship Order (no compensation - final step)
var shipOrder = romancy.DefineActivity("ship_order",
func(ctx context.Context, orderID string, address ShippingAddress) (ShipmentResult, error) {
fmt.Printf("🚚 Shipping %s to %s, %s\n", orderID, address.City, address.Country)
return ShipmentResult{
TrackingNumber: fmt.Sprintf("TRACK-%s", orderID),
Status: "shipped",
}, nil
},
)Step 3: Create the Workflow
Now orchestrate the activities:
var orderProcessingWorkflow = romancy.DefineWorkflow("order_processing",
func(ctx *romancy.WorkflowContext, input OrderInput) (OrderResult, error) {
// Step 1: Reserve inventory
reservation, err := reserveInventory.Execute(ctx, input.OrderID, input.Items)
if err != nil {
return OrderResult{}, err
}
// Step 2: Process payment
payment, err := processPayment.Execute(ctx, input.OrderID, reservation.TotalAmount, input.CustomerEmail)
if err != nil {
return OrderResult{}, err
}
// Step 3: Ship order
shipment, err := shipOrder.Execute(ctx, input.OrderID, input.ShippingAddress)
if err != nil {
return OrderResult{}, err
}
// Success! Return result
return OrderResult{
OrderID: input.OrderID,
Status: "completed",
TotalAmount: payment.Amount,
ConfirmationNumber: shipment.TrackingNumber,
}, nil
},
)Step 4: Run the Workflow
Add the main function:
func main() {
// Create Romancy app
app := romancy.NewApp(
romancy.WithDatabase("orders.db"),
romancy.WithWorkerID("worker-1"),
)
ctx := context.Background()
// Start the app (required before starting workflows)
if err := app.Start(ctx); err != nil {
log.Fatal(err)
}
defer app.Shutdown(ctx)
// Create order input
order := OrderInput{
OrderID: "ORD-12345",
CustomerEmail: "customer@example.com",
Items: []OrderItem{
{ProductID: "PROD-1", Quantity: 2, UnitPrice: 29.99},
{ProductID: "PROD-2", Quantity: 1, UnitPrice: 49.99},
},
ShippingAddress: ShippingAddress{
Street: "1-2-3 Dogenzaka",
City: "Shibuya",
PostalCode: "150-0001",
Country: "Japan",
},
}
// Start workflow
fmt.Println("Starting order processing workflow...")
instanceID, err := romancy.StartWorkflow(ctx, app, orderProcessingWorkflow, order)
if err != nil {
log.Fatal(err)
}
fmt.Printf("\n✅ Workflow started: %s\n", instanceID)
// Get result
instance, err := app.Storage().GetInstance(ctx, instanceID)
if err != nil {
log.Fatal(err)
}
if instance.Status == "completed" {
result := instance.OutputData
fmt.Println("📊 Order completed:")
fmt.Printf(" - Order ID: %s\n", result["order_id"])
fmt.Printf(" - Total: $%.2f\n", result["total_amount"])
fmt.Printf(" - Tracking: %s\n", result["confirmation_number"])
}
}Step 5: Test Happy Path
Run the workflow:
go run main.goExpected output:
Starting order processing workflow...
📦 Reserving inventory for ORD-12345: $109.97
💳 Processing payment for ORD-12345: $109.97
🚚 Shipping ORD-12345 to Shibuya, Japan
✅ Workflow started: <instance_id>
📊 Order completed:
- Order ID: ORD-12345
- Total: $109.97
- Tracking: TRACK-ORD-12345Step 6: Test Failure & Compensation
Let’s simulate a shipping failure to see compensation in action.
Modify shipOrder to fail:
var shipOrder = romancy.DefineActivity("ship_order",
func(ctx context.Context, orderID string, address ShippingAddress) (ShipmentResult, error) {
fmt.Printf("🚚 Shipping %s to %s, %s\n", orderID, address.City, address.Country)
// Simulate shipping failure
return ShipmentResult{}, fmt.Errorf("shipping service unavailable")
},
)Run again:
go run main.goExpected output:
📦 Reserving inventory for ORD-12345: $109.97
💳 Processing payment for ORD-12345: $109.97
🚚 Shipping ORD-12345 to Shibuya, Japan
💥 Error: shipping service unavailable
❌ Refunding payment for ORD-12345: $109.97
❌ Cancelling inventory reservation for ORD-12345
Error: shipping service unavailableWhat happened:
- Inventory reserved ✅
- Payment processed ✅
- Shipping failed ❌
- Automatic compensation in reverse order:
- Refund payment ✅
- Cancel inventory reservation ✅
This is the Saga pattern - distributed rollback through compensation functions.
Step 7: Understanding Crash Recovery
Romancy’s durable execution ensures workflows survive crashes through deterministic replay. When a workflow crashes mid-execution:
- ✅ Activity results are saved to the database before execution continues
- ✅ Workflow state is preserved (current step, history, locks)
- ✅ Automatic recovery detects and resumes stale workflows
How Automatic Recovery Works
In production environments with long-running Romancy app instances:
- Crash detection: Romancy’s background task checks for stale locks every 60 seconds
- Auto-resume: Crashed workflows are automatically resumed when their lock timeout expires
- Both normal execution and rollback execution are automatically resumed
- Default timeout: 5 minutes (300 seconds)
- Workflows resume from their last checkpoint using deterministic replay
- Deterministic replay: Previously executed activities return cached results from history
- Resume from checkpoint: Only remaining activities execute fresh
Workflows Waiting for Events or Timers
Workflows in special waiting states are handled differently:
- Waiting for Events: Resumed immediately when the awaited event arrives (not on a fixed schedule)
- Waiting for Timers: Checked every 10 seconds and resumed when the timer expires
- These workflows are not included in the 60-second crash recovery cycle
Crash Recovery in Action
Production scenario:
// Server starts and runs continuously
app := romancy.NewApp(
romancy.WithDatabase("postgres://user:password@localhost/orders"),
romancy.WithWorkerID("worker-1"),
)
app.Start(ctx)
// Workflow starts executing
instanceID, _ := romancy.StartWorkflow(ctx, app, orderProcessingWorkflow, order)
// Server crashes after payment step
// → inventory reservation: ✅ saved
// → payment: ✅ saved
// → shipping: ❌ not executed
// Server restarts (automatic or manual)
// → Romancy's background task detects stale workflow (lock > 5 minutes)
// → Automatically resumes workflow from last checkpoint
// → inventory reservation: ⚡ replayed from history (instant)
// → payment: ⚡ replayed from history (instant)
// → shipping: 🚚 executes freshWhy Activities Execute Exactly Once
Romancy’s replay mechanism ensures idempotency:
- Before execution: Check if result exists in history for current step
- If found: Return cached result (replay)
- If not found: Execute activity and save result to history
- Side effects: External API calls, payments, etc. happen exactly once
Example:
type PaymentResult struct {
TransactionID string `json:"transaction_id"`
Amount float64 `json:"amount"`
Status string `json:"status"`
}
var processPayment = romancy.DefineActivity("process_payment",
func(ctx context.Context, orderID string, amount float64) (PaymentResult, error) {
// This code executes ONCE per workflow instance
// On crash recovery, cached result is returned
fmt.Printf("💳 Processing payment for %s: $%.2f\n", orderID, amount)
paymentResult, err := externalPaymentAPI.Charge(amount)
if err != nil {
return PaymentResult{}, err
}
return PaymentResult{
TransactionID: paymentResult.ID,
Amount: amount,
Status: "completed",
}, nil
},
romancy.WithCompensation(refundPayment),
)On first execution:
- Code executes
- External payment API is called
- Result saved to database
- Output:
💳 Processing payment for ORD-12345: $109.97
On crash recovery (replay):
- Code does NOT execute
- Result loaded from database
- External payment API is NOT called again
- No output (instant return)
Testing Crash Recovery
For a full demonstration, you would need:
- Long-running Romancy app instance (e.g., HTTP server)
- Workflow that crashes mid-execution
- Wait 5+ minutes for automatic recovery
- Observe workflow resume from last checkpoint
Note: Running the same script twice creates separate workflow instances with different UUIDs. To test replay on the same instance, you need a persistent server and workflow resumption logic.
What You’ve Learned
- ✅ Type-Safe Structs: Go structs for inputs and outputs
- ✅ Activities: Business logic units with automatic history recording
- ✅ Compensation: Automatic rollback with
WithCompensation - ✅ Saga Pattern: Distributed transaction management
- ✅ Durable Execution: Workflows survive crashes
- ✅ Deterministic Replay: Activities execute exactly once
Next Steps
- Saga Pattern: Deep dive into compensation
- Event Handling: Wait for external events
- Transactional Outbox: Reliable event publishing
- Examples: More real-world examples