Core Concepts
This guide introduces the fundamental concepts of Romancy: workflows, activities, durable execution, and the Saga pattern.
Workflows vs. Activities
Romancy separates orchestration logic (workflows) from business logic (activities).
Activities
Activity: A unit of work that performs business logic.
type EmailResult struct {
Sent bool `json:"sent"`
MessageID string `json:"message_id"`
}
var sendEmail = romancy.DefineActivity("send_email",
func(ctx context.Context, email, message string) (EmailResult, error) {
// Business logic - sends an actual email
response, err := emailService.Send(email, message)
if err != nil {
return EmailResult{}, err
}
return EmailResult{Sent: true, MessageID: response.ID}, nil
},
)Key characteristics:
- Execute business logic (database writes, API calls, file I/O)
- Activity return values are automatically saved to history for deterministic replay
- On replay, return cached results from history (idempotent)
- Automatically transactional (by default)
Workflows
Workflow: Orchestration logic that coordinates activities.
type UserResult struct {
UserID string `json:"user_id"`
}
type SignupResult struct {
UserID string `json:"user_id"`
Status string `json:"status"`
}
var userSignup = romancy.DefineWorkflow("user_signup",
func(ctx *romancy.WorkflowContext, email, name string) (SignupResult, error) {
// Step 1: Create user account
user, err := createAccount.Execute(ctx, email, name)
if err != nil {
return SignupResult{}, err
}
// Step 2: Send welcome email
_, err = sendEmail.Execute(ctx, email, fmt.Sprintf("Welcome, %s!", name))
if err != nil {
return SignupResult{}, err
}
// Step 3: Initialize user settings
_, err = setupDefaultSettings.Execute(ctx, user.UserID)
if err != nil {
return SignupResult{}, err
}
return SignupResult{UserID: user.UserID, Status: "active"}, nil
},
)Key characteristics:
- Coordinate activities (orchestration, not business logic)
- Can be replayed from history after crashes
- Deterministic replay - workflow replays the same execution path using saved activity results
- Resume from the last checkpoint automatically
Durable Execution
Romancy ensures workflow progress is never lost through deterministic replay.
How It Works
type ProcessOrderResult struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
}
var processOrder = romancy.DefineWorkflow("process_order",
func(ctx *romancy.WorkflowContext, orderID string) (ProcessOrderResult, error) {
// Step 1: Reserve inventory
reservation, err := reserveInventory.Execute(ctx, orderID) // Saved to history
if err != nil {
return ProcessOrderResult{}, err
}
// Process crashes here!
// Step 2: Charge payment
payment, err := chargePayment.Execute(ctx, orderID)
if err != nil {
return ProcessOrderResult{}, err
}
return ProcessOrderResult{OrderID: orderID, Status: "completed"}, nil
},
)On crash recovery:
- Workflow restarts from the beginning
- Step 1 (reserveInventory): Returns cached result from history (does NOT execute again)
- Step 2 (chargePayment): Executes fresh (continues from checkpoint)
Key Guarantees
- Activities execute exactly once (results cached in history)
- Workflows survive arbitrary crashes (process restarts, container failures, etc.)
- No manual checkpoint management required
- Deterministic replay - predictable behavior
WorkflowContext
The WorkflowContext object provides access to workflow operations.
Key Properties
| Property/Method | Description |
|---|---|
ctx.InstanceID() | Workflow instance ID |
ctx.WorkflowName() | Name of the workflow function |
ctx.IsReplaying() | true if replaying from history |
ctx.Session() | Access Romancy’s managed database session |
The Saga Pattern
When a workflow fails, Romancy automatically executes compensation functions for already-executed activities in reverse order.
This implements the Saga pattern for distributed transaction rollback.
Basic Compensation
type ReservationResult struct {
Reserved bool `json:"reserved"`
ItemID string `json:"item_id"`
}
type OrderWorkflowResult struct {
Status string `json:"status"`
}
// Compensation function
var cancelReservation = romancy.DefineCompensation("cancel_reservation",
func(ctx context.Context, itemID string) error {
log.Printf("Cancelled reservation for %s", itemID)
return nil
},
)
// Activity with compensation
var reserveInventory = romancy.DefineActivity("reserve_inventory",
func(ctx context.Context, itemID string) (ReservationResult, error) {
log.Printf("Reserved %s", itemID)
return ReservationResult{Reserved: true, ItemID: itemID}, nil
},
romancy.WithCompensation(cancelReservation),
)
// Workflow
var orderWorkflow = romancy.DefineWorkflow("order_workflow",
func(ctx *romancy.WorkflowContext, item1, item2 string) (OrderWorkflowResult, error) {
_, err := reserveInventory.Execute(ctx, item1) // Step 1
if err != nil {
return OrderWorkflowResult{}, err
}
_, err = reserveInventory.Execute(ctx, item2) // Step 2
if err != nil {
return OrderWorkflowResult{}, err
}
_, err = chargePayment.Execute(ctx) // Step 3: Fails!
if err != nil {
return OrderWorkflowResult{}, err
}
return OrderWorkflowResult{Status: "completed"}, nil
},
)Execution:
Reserved item1
Reserved item2
chargePayment fails!
Cancelled reservation for item2 # Reverse order
Cancelled reservation for item1Compensation Rules
- Reverse Order: Compensations run in reverse order of activity execution
- Already-Executed Only: Only activities that completed successfully are compensated
- Automatic: No manual trigger required - Romancy handles it
AI Agent Workflows
Romancy is well-suited for orchestrating AI agent workflows that involve long-running tasks:
Why Romancy for AI Agents?
- Durable LLM Calls: Long-running LLM inference with automatic retry on failure
- Multi-Step Reasoning: Coordinate multiple AI tasks (research -> analysis -> synthesis)
- Tool Usage Workflows: Orchestrate AI agents calling external tools/APIs with crash recovery
- Compensation on Failure: Automatically rollback AI agent actions when workflows fail
Example: Research Agent Workflow
type ResearchResult struct {
Research string `json:"research"`
}
type AnalysisResult struct {
Analysis string `json:"analysis"`
}
type ReportResult struct {
Report string `json:"report"`
}
var researchTopic = romancy.DefineActivity("research_topic",
func(ctx context.Context, topic string) (ResearchResult, error) {
// Call LLM to research a topic (may take minutes)
result, err := llmClient.Generate(fmt.Sprintf("Research: %s", topic))
if err != nil {
return ResearchResult{}, err
}
return ResearchResult{Research: result}, nil
},
)
var analyzeResearch = romancy.DefineActivity("analyze_research",
func(ctx context.Context, research string) (AnalysisResult, error) {
// Analyze research results with another LLM call
analysis, err := llmClient.Generate(fmt.Sprintf("Analyze: %s", research))
if err != nil {
return AnalysisResult{}, err
}
return AnalysisResult{Analysis: analysis}, nil
},
)
var synthesizeReport = romancy.DefineActivity("synthesize_report",
func(ctx context.Context, analysis string) (ReportResult, error) {
// Create final report
report, err := llmClient.Generate(fmt.Sprintf("Report: %s", analysis))
if err != nil {
return ReportResult{}, err
}
return ReportResult{Report: report}, nil
},
)
var aiResearchWorkflow = romancy.DefineWorkflow("ai_research_workflow",
func(ctx *romancy.WorkflowContext, topic string) (ReportResult, error) {
// Step 1: Research (may take 2-3 minutes)
research, err := researchTopic.Execute(ctx, topic)
if err != nil {
return ReportResult{}, err
}
// Step 2: Analyze (if crash happens here, Step 1 won't re-run)
analysis, err := analyzeResearch.Execute(ctx, research.Research)
if err != nil {
return ReportResult{}, err
}
// Step 3: Synthesize
report, err := synthesizeReport.Execute(ctx, analysis.Analysis)
if err != nil {
return ReportResult{}, err
}
return report, nil
},
)Key benefits:
- If the workflow crashes during Step 2, Step 1 (research) won’t re-run - cached results are used
- Each LLM call is automatically retried on transient failures
- Workflow state is persisted, allowing multi-hour AI workflows to survive restarts
- Compensation functions can undo AI agent actions (e.g., delete created resources) on failure
Distributed Execution
Multiple workers can safely process workflows.
Multi-Worker Setup
// Worker 1, Worker 2, Worker 3 (same code on different machines)
app := romancy.NewApp(
romancy.WithDatabase("postgres://yourdbinstance/workflows"), // Shared database
romancy.WithWorkerID("worker-1"), // Unique per worker
)Features:
- Exclusive Execution: Only one worker can execute a workflow instance at a time
- Stale Lock Cleanup: Automatic cleanup of locks from crashed workers (5-minute timeout)
- Automatic Resume: Crashed workflows resume on any available worker
How It Works
Worker 1: Tries to acquire lock for workflow instance A
-> Lock acquired
-> Executes workflow
Worker 2: Tries to acquire lock for same instance A
-> Lock already held by Worker 1
-> Skips, moves to next instance
Worker 1: Crashes during execution
Cleanup Task: Detects stale lock (5 minutes old)
-> Releases lock
-> Marks workflow for resume
Worker 3: Acquires lock for instance A
-> Replays from history
-> Completes workflowType Safety with Go Generics
Romancy uses Go generics for type-safe workflows:
// Type-safe input/output
type OrderInput struct {
OrderID string `json:"order_id"`
CustomerEmail string `json:"customer_email"`
Amount float64 `json:"amount"`
}
type OrderResult struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
Total float64 `json:"total"`
}
// Type-safe activity
var processPayment = romancy.DefineActivity("process_payment",
func(ctx context.Context, input OrderInput) (OrderResult, error) {
// Input is typed
total := input.Amount * 1.1 // Add 10% tax
return OrderResult{
OrderID: input.OrderID,
Status: "completed",
Total: total,
}, nil
},
)Benefits:
- Compile-time type checking
- IDE autocomplete
- No runtime type assertions needed
Next Steps
Now that you understand the core concepts:
- Your First Workflow: Build a complete workflow step-by-step
- Saga Pattern: Deep dive into compensation
- Durable Execution: Technical details of replay
- Examples: Real-world examples