When to read this: Before submitting any activity code for review. This is a checklist of rules distilled from real production issues. Violating the versioning rules in particular can break deployed workflows for all users.
-
Separate business logic from workflow context. Put core logic in a public
ExecuteInternal()method (or similar) that takes plain parameters. This makes unit testing possible without mocking the workflow runtime.// Good: testable business logic separated from context public class EmailSender : CodeActivity { protected override void Execute(CodeActivityContext context) { var to = To.Get(context); var subject = Subject.Get(context); var body = Body.Get(context); var messageId = SendEmail(to, subject, body); MessageId.Set(context, messageId); } // Public, testable, no workflow dependency public string SendEmail(string to, string subject, string body) { // Business logic here return Guid.NewGuid().ToString(); } }
-
Use
[RequiredArgument]for mandatory properties. The workflow engine validates these before execution. -
Use
InArgument<T>for inputs that should accept expressions/variables. Use direct types (plain properties) for constants/enums that are always set at design time. -
Use
[DefaultValue]on all properties for backward compatibility when adding new properties to an existing activity. -
Mark sensitive properties with
[Sensitive]to exclude them from telemetry and logging.
-
Property names must exactly match the Activity class property names. A mismatch silently fails -- the property will not be serialized or deserialized.
-
Always call
base.InitializeModel()andPersistValuesChangedDuringInit()at the start ofInitializeModel():protected override void InitializeModel() { base.InitializeModel(); // must be first PersistValuesChangedDuringInit(); // must be second // ... configure properties }
-
Use
IsPrincipal = truefor the most important 2-4 properties. These appear in the main panel and cannot be collapsed. -
Use
OrderIndexto control property display order. Use an incrementing counter:var order = 0; To.OrderIndex = order++; Subject.OrderIndex = order++; Body.OrderIndex = order++;
-
Localize all user-visible strings through resource files. Never hardcode display names:
// Good To.DisplayName = Resources.EmailSender_To_DisplayName; // Bad -- not localizable To.DisplayName = "Recipient";
-
Use partial classes for large ViewModels to separate concerns (Configuration, Rules, Actions).
-
Keep rules focused. Each rule should handle one concern. Name rules after the property they react to.
See ../design/viewmodel.md for the full ViewModel reference.
-
Default behavior is usually correct. Only set a widget explicitly when you need something specific.
-
Use
AutoCompleteForExpressionwhen the user should pick from a list but also be able to type an expression. -
Use
Dropdownwhen the user must pick from a fixed set of values. -
Use
Togglefor boolean properties. -
Use
PlainNumberwith Min/Max/Step when you need constrained numeric input without expressions. -
Use
TextBlockWidgetfor read-only information display. -
Check widget availability with
HasFeature()andIsWidgetSupported()before using platform-specific widgets:if (workflowDesignApi.HasFeature(DesignFeatureKeys.SomeFeature)) { MyProperty.Widget = new DefaultWidget { Type = ViewModelWidgetType.SpecificWidget }; }
See ../design/widgets.md for the complete widget reference.
-
Use static DataSource when the list is small and known at design time.
-
Use
IDynamicDataSourceBuilderwhen data comes from an API or depends on other properties. -
Register dependencies when a data source depends on another property's value.
-
Add async validators for data sources that need server-side validation.
See ../design/datasources.md for DataSource patterns.
-
Always check
IUserDesignContext.SolutionIdto determine if running in a Solution context. -
Use
SolutionResourcesWidgetfor Solution-managed resources. -
Hide
FolderPathwhen in Solutions context (managed by the Solution):var solutionId = userDesignContext?.SolutionId; if (!string.IsNullOrEmpty(solutionId)) { FolderPath.IsVisible = false; }
-
Provide both paths: Implement
InitializeProjectScopePropertiesAsync()andInitializeSolutionsScopePropertiesAsync().
-
Always use
HasFeature()before using platform-specific APIs:var designApi = Services.GetService<IWorkflowDesignApi>(); if (designApi?.HasFeature(DesignFeatureKeys.StudioDesignSettingsV3) == true) { // Safe to use V3 API }
-
Use
[MethodImpl(MethodImplOptions.NoInlining)]when calling methods that reference types from optional assemblies. This prevents JIT from trying to load the assembly when the calling method is compiled:[MethodImpl(MethodImplOptions.NoInlining)] private void ConfigureDesktopOnlyFeature() { // References types from an assembly only available on Desktop }
-
Gracefully degrade when features are unavailable rather than throwing exceptions.
-
Use
ValueTaskinstead ofTaskfor async ViewModel methods that often complete synchronously. -
Use
Dispatcher.Invoke()when updating properties from background threads. -
Use
IBusyServiceto show loading indicators during long async operations. -
Minimize rule execution. Set
runOnInit: falsewhen the rule does not need to run during initialization:Rules.Add(new Rule<MyViewModel>( nameof(SomeProperty), (ctx) => { /* rule logic */ }, runOnInit: false // only runs when SomeProperty changes ));
-
Unit test business logic separately from the workflow context (via the
ExecuteInternal()pattern). -
Workflow test the full activity using
WorkflowInvokerwith mocked extensions:var invoker = new WorkflowInvoker(new MyActivity()); invoker.Extensions.Add(mockExecutorRuntime); var result = invoker.Invoke( new Dictionary<string, object> { ["Input"] = "test" }, TimeSpan.FromSeconds(10) // always set a timeout );
-
ViewModel test initialization, rules, and property visibility.
-
Mock
IExecutorRuntimeto verify logging behavior. -
Use
TimeSpantimeouts inWorkflowInvoker.Invoke()to prevent hanging tests.
These rules protect deployed workflows from breaking. Violations affect all users who have installed your activity package.
-
Never rename or delete activity arguments/properties. Existing
.xamlworkflows serialize activity properties by name. Renaming or removing a property causes a deserialization error when the workflow loads, breaking all projects that use the activity. -
New properties require
[DefaultValue]so that existing workflows load without errors (the missing property gets the default):// Adding a new property to an existing activity [DefaultValue(30000)] public InArgument<int> TimeoutMS { get; set; }
-
To deprecate a property: Add
[Obsolete]and[Browsable(false)]. The property must remain on the class -- hide it from the designer, but keep it loadable:[Obsolete("Use TimeoutMS instead")] [Browsable(false)] public InArgument<int> Timeout { get; set; }
-
Minor version bump for any public property or activity additions.
-
Major version bump for breaking changes (e.g., changing a property's type).
- Activity context rules:
activity-context.md(the "Read Before Await" pattern) - Project structure and packaging:
project-structure.md - Architecture overview:
architecture.md - ViewModel patterns:
../design/viewmodel.md - Widget reference:
../design/widgets.md