Versioning in Dapr Workflows - Balancing Determinism with Business Agility
Versioning in Dapr Workflows: Balancing Determinism with Business Agility
When building long-running business processes with Dapr Workflows, you’ll eventually face the challenge of evolving your workflow logic over time. Workflows are often long-lived, while business requirements naturally change. This creates tension between maintaining backward compatibility for in-flight instances and introducing updates to reflect new business rules. In this post, we’ll explore this challenge in depth and share practical strategies to evolve your workflows without breaking existing instances.
Understanding the Versioning Challenge
Dapr Workflows, like all workflow engines, rely on deterministic execution. This determinism is what enables powerful features like recoverability and replayability. However, it also introduces challenges when you need to update your business logic over time.
The core problem: When workflow code changes while instances are in-flight, you risk corrupting the execution state of those instances. Any change to activity names, parameter structures, or execution flow can break the workflow’s ability to replay correctly from its saved history.
This challenge isn’t unique to Dapr. All workflow technologies face similar versioning issues. Even simple DAG-based workflow engines must handle versioning challenges whenever task dependencies, implementations, schemas, or branching logic change.
Why Workflow Versioning Is Particularly Challenging
Unlike stateless services—where you can simply deploy and route traffic to a new version—workflows maintain state and can often be long lived. This introduces several complications:
1. Execution History Dependency
Workflows persist an execution history that records:
- Which activities were called and in what order
- The input parameters passed to each activity
- The results returned from each activity
When you modify workflow code, the replay mechanism attempts to reconcile this stored history with the new code—often resulting in errors or unexpected behavior.
2. Common Modifications That Break Determinism
Several types of changes almost always break in-flight workflows:
Function Signature Changes
// Original
await ctx.CallActivityAsync("ProcessPayment", paymentInfo);
// Changed (breaks existing workflows)
await ctx.CallActivityAsync("ProcessCustomerPayment", paymentInfo);
Parameter Structure Modifications
// Original
await ctx.CallActivityAsync("ShipOrder", new { OrderId = id });
// Changed (breaks existing workflows)
await ctx.CallActivityAsync("ShipOrder", new { Id = id }); // Renamed property
Logic Flow Alterations
// Original
await ctx.CallActivityAsync("ValidateOrder", order);
await ctx.CallActivityAsync("ProcessPayment", payment);
// Changed (breaks existing workflows)
if (order.Total > 1000) {
await ctx.CallActivityAsync("ValidateOrder", order);
await ctx.CallActivityAsync("ApproveOrder", order);
}
await ctx.CallActivityAsync("ProcessPayment", payment);
Adding or Removing Activities
// Original
await ctx.CallActivityAsync("ProcessPayment", payment);
await ctx.CallActivityAsync("ShipOrder", order);
// Changed (breaks existing workflows)
await ctx.CallActivityAsync("ProcessPayment", payment);
await ctx.CallActivityAsync("UpdateInventory", items); // New step
await ctx.CallActivityAsync("ShipOrder", order);
Three Effective Versioning Approaches
Let’s explore three practical approaches to versioning Dapr Workflows, each with different trade-offs.
Approach 1: Creating New Workflow Types (Dapr’s Recommended Approach)
This approach involves creating an entirely new workflow whenever a change breaks determinism. From a technical standpoint, it’s the cleanest and safest option, as it fully isolates workflow versions. This separation ensures that in-flight instances continue running the original definition, while new instances use the updated logic—eliminating any risk of cross-version interference.
One side effect of this apprach is that clients must be updated to make use of the new workflow version. This is similar to versioning in APIs—for example, introducing a new endpoint like api/v2/order allows api/v1/order to continue functioning, but clients need to explicitly switch to the new version to benefit from any changes.
In some cases, this versioning strategy is actually preferred, especially when input or result types evolve. It allows clients to continue using the original workflow until they’re ready to accommodate the updated contract.
// Keep original untouched for in-flight instances
public class OrderWorkflow { ... }
// Create new version for new instances
public class OrderWorkflowV2 { ... }
When to Use This Approach
- When safety is your top priority
- When client changes are manageable
- When you want clients to specifically identify workflow versions
- When you need to version workflows that are already in production
Advantages
- Clean Separation: Each version exists as a completely separate entity
- Simplicity: Straightforward implementation without conditional logic
- Minimal Risk: Very low chance of accidentally breaking existing instances
- Clear Contract: API consumers know exactly which version they’re calling
- Simpler Testing: Each version can be tested independently
Disadvantages
- Client Updates Required: All clients must be updated to reference the new workflow type
- Code Duplication: Your codebase accumulates multiple versions of similar workflows
- Maintenance Burden: Each version must be maintained until all instances complete
- Cleanup Complexity: Determining when it’s safe to remove old versions can be challenging
Approach 2: Versioning Through an Activity
This approach uses an activity at the beginning of the workflow to determine which version of the logic to execute. Unlike creating separate workflow types, this method maintains a single workflow definition with internal branching logic based on version. The key component is a dedicated versioning activity that runs at workflow start to determine which “branch” of logic should be executed. This activity can implement sophisticated version selection rules based on instance IDs, timestamps, user attributes, or other contextual information. The determination happens only once per workflow instance and is recorded in the execution history, ensuring consistent replay behavior even as the workflow definition evolves over time.
public async Task RunAsync(WorkflowContext context, WorkflowInput input)
{
// Determine version at the start of workflow execution
var version = await context.CallActivityAsync<string>(
"GetWorkflowVersion",
new { WorkflowName = "order-processing", InstanceId = context.InstanceId }
);
// Branch logic based on version
if (version == "v1")
{
// Activities for version 1
}
else if (version == "v2")
{
// Activities for version 2
}
}
⚠️ Critical Warning: This approach must be implemented from the very first deployment of your workflow. You cannot retroactively add it to existing workflows without breaking determinism for in-flight instances.
When to Use This Approach
- When client stability is important
- When you need finer-grained rollout control - your GetWorkflowVersion method could impliment business logic
- Only when you can implement it from the beginning
Advantages
- Single Workflow Definition: Clients always call the same workflow name
- Centralized Control: Version logic is isolated to a single activity
- Operational Flexibility: Version assignments can incorporate business rules
- Simplified Deployment: Only one workflow definition to deploy and manage
- Gradual Migration: Easier to implement canary deployments or A/B testing
Disadvantages
- Increased Complexity: Workflow code becomes more complex with branching logic
- Careful Management Required: Must ensure the version decision is deterministic
- Potential for Mistakes: Greater risk of accidentally altering behavior of existing instances
- Testing Challenges: Must test all version paths in a single workflow
- Code Organization: The workflow file becomes cluttered as versions multiply
Approach 3: Strategy Pattern - A Middle Ground?
This approach builds on the Version Activity concept but aims to reduce the complexity of internal branching. The Strategy Pattern offers a middle-ground solution that blends the strengths of both earlier approaches. It retains a single workflow entry point (similar to Approach 2) while isolating version-specific logic (like in Approach 1), making the workflow easier to manage and evolve over time.
// Clients always call this same workflow type (no client changes needed)
public class OrderWorkflow : Workflow<Transaction, bool>
{
public override async Task<bool> RunAsync(WorkflowContext context, Transaction input)
{
// Version determination happens in one place
var version = await context.CallActivityAsync<string>(
nameof(VersioningActivity),
new { WorkflowName = "Orders" }
);
// Delegate to the appropriate strategy implementation
var strategy = WorkflowStrategyRegistry.GetStrategy<Transaction,bool>("Orders", version);
return await strategy.ExecuteAsync(context, input);
}
}
// Each version's logic lives in its own clean, separate class (like having separate workflow types)
public class OrderWorkflowV1Strategy : IWorkflowStrategy<Transaction, bool>
{
public async Task<bool> ExecuteAsync(WorkflowContext context, Transaction input)
{
// Version 1 implementation
}
}
public class OrderWorkflowV2Strategy : IWorkflowStrategy<Transaction, bool>
{
public async Task<bool> ExecuteAsync(WorkflowContext context, Transaction input)
{
// Version 2 implementation with completely different logic
}
}
The Best of Both Worlds
-
No Client Updates: Like Approach 2, clients always use the same workflow name, so client code never needs to change when you add a new version
-
Clean Code Separation: Like Approach 1, each version’s implementation is completely separate, making the code more maintainable and testable
-
Centralized Version Control: The main workflow handles only version determination, delegating all version-specific logic to dedicated strategy classes
-
Flexible Evolution: You can add new versions without cluttering the main workflow or updating clients
Implementation Details
Let’s see a complete implementation of this middle-ground approach:
Step 1: Define the Strategy Interface and Implementations
public interface IWorkflowStrategy<TInput,TResult>: IWorkflowStrategy
{
Task<TResult> ExecuteAsync(WorkflowContext context, TInput input);
}
public class OrderWorkflowV1Strategy : IWorkflowStrategy<Transaction, bool>
{
public async Task<bool> ExecuteAsync(WorkflowContext context, Transaction input)
{
await context.CallActivityAsync(nameof(ProcessPaymentActivity), input);
await context.CallActivityAsync(nameof(NotifyWarehouseActivity), input);
await context.WaitForExternalEventAsync<bool>("Shipped");
context.SetCustomStatus("Success!");
return true;
}
}
public class OrderWorkflowV2Strategy : IWorkflowStrategy<Transaction, bool>
{
public async Task<bool> ExecuteAsync(WorkflowContext context, Transaction input)
{
await context.CallActivityAsync(nameof(ConfirmInventoryActivity), input); // new activity
await context.CallActivityAsync(nameof(ProcessPaymentActivity), input);
await context.CallActivityAsync(nameof(NotifyWarehouseActivity), input);
await context.WaitForExternalEventAsync<bool>("Shipped");
context.SetCustomStatus("Success!");
return true;
}
}
Step 2: Create a Strategy Registry
public static class WorkflowStrategyRegistry
{
private static readonly Dictionary<string, Dictionary<string, IWorkflowStrategy>> _strategies = new();
public static void RegisterStrategy(string workflowName, string version, IWorkflowStrategy strategy)
{
if (!_strategies.ContainsKey(workflowName))
{
_strategies[workflowName] = new Dictionary<string, IWorkflowStrategy>();
}
_strategies[workflowName][version] = strategy;
}
public static IWorkflowStrategy<TInput,TResult> GetStrategy<TInput, TResult>(string workflowName, string version)
{
if (!_strategies.TryGetValue(workflowName, out var workflowStrategies) ||
!workflowStrategies.TryGetValue(version, out var strategy))
{
throw new InvalidOperationException($"No strategy found for workflow '{workflowName}' version '{version}'");
}
return (IWorkflowStrategy<TInput,TResult>)strategy;
}
}
Step 3: Implement a Version-Aware Workflow
public class OrderWorkflow : Workflow<Transaction, bool>
{
public override async Task<bool> RunAsync(WorkflowContext context, Transaction input)
{
// Get version from activity
var version = await context.CallActivityAsync<string>(
nameof(VersioningActivity),
new { WorkflowName = "Orders" }
);
// Get appropriate strategy from registry
var strategy = WorkflowStrategyRegistry.GetStrategy<Transaction,bool>("Orders", version);
return await strategy.ExecuteAsync(context, input);
}
}
Step 4: Register Strategies at Startup
public static class Program
{
public static void Main(string[] args)
{
var builder = Host.CreateDefaultBuilder(args).ConfigureServices(services =>
{
services.AddDaprWorkflow(options =>
{
options.RegisterWorkflow<DemoWorkflow>();
options.RegisterWorkflow<OrderWorkflow>();
options.RegisterActivity<ProcessPaymentActivity>();
options.RegisterActivity<NotifyWarehouseActivity>();
options.RegisterActivity<ConfirmInventoryActivity>();
options.RegisterActivity<VersioningActivity>();
});
});
RegisterWorkflowStrategies();
var host = await builder.StartAsync();
}
private static void RegisterWorkflowStrategies()
{
// Register V1 strategy
WorkflowStrategyRegistry.RegisterStrategy(
"Orders",
"V1",
new OrderWorkflowV1Strategy()
);
// Register V2 strategy
WorkflowStrategyRegistry.RegisterStrategy(
"Orders",
"V2",
new OrderWorkflowV2Strategy()
);
}
}
When to Use This Middle-Ground Approach
- When you want the client stability of activity-based versioning but the code cleanliness of separate workflow types
- When you have multiple versions that share common structure but differ in specific implementations
- When you can implement it from the beginning of your workflow development
- When team collaboration requires clear separation between version implementations
Advantages
- No Client Changes: Workflow name stays consistent across versions
- Clean Separation: Each version’s logic is encapsulated in its own class
- Improved Testability: Each strategy can be tested in isolation
- Consistent Interface: All versions implement the same methods
- Scalable Organization: Easy to add new versions without cluttering the main workflow
- Clear Responsibilities: Each class has a single purpose
Disadvantages
- More Complex Setup: Requires additional interfaces and configuration
- Careful Interface Design: Strategy interface must accommodate all versions
- Must Be Planned from Start: Cannot be retroactively applied
Advanced Versioning Techniques
Implementing Canary Deployments
Canary deployments represent a sophisticated approach to reducing risk when rolling out new workflow versions. Rather than switching all new instances to a new version at once, this technique enables gradual adoption with careful monitoring of results. The implementation centers around a flexible versioning activity that makes dynamic routing decisions based on configurable rules. This technique gives operations teams fine-grained control over the rollout process, allowing them to start with a small percentage of traffic (e.g., 5-10%) directed to the new version, then gradually increase the percentage as confidence grows. If issues arise, the canary percentage can be immediately reduced or set to zero, effectively rolling back the deployment without affecting in-flight instances.
public string GetWorkflowVersion([ActivityTrigger] VersionRequest request)
{
// For new instances, implement canary logic
var canaryPercentage = _config.GetCanaryPercentage(request.WorkflowName);
var random = new Random().NextDouble() * 100;
if (random < canaryPercentage)
{
return "v2"; // Canary version
}
return "v1"; // Default version
}
This approach enables you to:
- Test new versions with a small percentage of traffic
- Gradually increase the rollout as confidence builds
- Roll back quickly if issues are detected
Choosing the Right Approach: A Comparison
Approach | When to Use | Key Benefits | Key Drawbacks |
---|---|---|---|
New Workflow Types | • Existing workflows • Safety-critical processes • Require client to update | • Simple implementation • Low risk • Clear separation | • Client updates required • Code duplication |
Activity-Based Versioning | • New workflows only • Infrequent changes • Can not update clients | • No client changes • Centralized control • Simplified deployment | • More complex workflow code • Higher risk of errors • Code becomes cluttered |
Strategy Pattern | • New workflows only • Need both client stability and code separation • Team collaboration on different versions | • No client changes • Clean separation • Improved testability | • More complex initial setup • Additional abstraction • Must be planned from start |
Planning for Success: Key Takeaways
-
Plan for Versioning from the Start: Even if you only have one version initially, design your workflows with change in mind.
-
Match Your Approach to Your Context:
- For existing workflows in production: Use new workflow types
- For new workflows where client updates are not desired: Consider activity-based versioning or the strategy pattern
- For the best of both worlds: Consider the strategy pattern middle-ground approach
-
Consider the Full Lifecycle:
- How will you determine when old versions can be safely retired?
- How will you monitor and track which versions are in use?
- How will you handle emergency fixes that need to be applied across versions?
-
Test Thoroughly: Workflow versioning adds complexity, so comprehensive testing is essential.
Conclusion
Versioning is an inevitable challenge in any long-running workflow system. The fundamental tension between deterministic execution and evolving business requirements exists in all workflow engines.
By understanding the versioning options available in Dapr Workflows and planning for change from the beginning, you can create systems that maintain both the technical guarantees of deterministic workflows and the business agility needed to evolve processes over time.
The Strategy Pattern approach offers a particularly compelling middle ground—combining the client stability of activity-based versioning with the clean separation of new workflow types. For teams starting new workflow development, this approach merits serious consideration.
What approach are you using for versioning your Dapr workflows? Have you found success with one of these approaches or developed your own solution? I’d love to hear about your experiences in the comments!
#Dapr #Workflows #Versioning #Microservices #CSharp #DistributedSystems
~Aaron Redmond