Plugins & Modules

Complete guide to extending Elsa Workflows with custom modules, features, and activities. Learn how to create reusable plugins and distribute them as NuGet packages.

Elsa Workflows provides a powerful and flexible extensibility system that allows you to create custom modules, features, and activities tailored to your specific needs. This guide will teach you how to extend Elsa with your own functionality and package it for reuse across projects.

Overview

The Elsa extensibility model is built around three core concepts:

  • Modules: Containers that group related features and provide a unified configuration interface

  • Features: Self-contained units of functionality that register services, activities, and other components

  • Activities: The building blocks of workflows that encapsulate specific actions or operations

This architecture enables you to:

  • Create domain-specific activities that encapsulate business logic

  • Package and distribute reusable extensions as NuGet packages

  • Maintain clean separation of concerns in large applications

  • Configure complex functionality through simple, fluent APIs

Table of Contents

Key Concepts

Modules & Features

Elsa uses a hierarchical configuration system where modules contain features, and features register the actual services and components.

IModule Interface

The IModule interface represents a container for features. The Elsa configuration system provides a default module implementation that you'll typically work with through extension methods.

FeatureBase Class

FeatureBase is the base class for all features in Elsa. A feature:

  • Encapsulates related functionality

  • Registers services with dependency injection

  • Can configure workflow options

  • Follows a two-phase initialization: Configure() and Apply()

Lifecycle Methods:

  1. Configure(): Called during application startup to register services and configure options. This is where you:

    • Register activities using AddActivitiesFrom<T>()

    • Register workflows using AddWorkflowsFrom<T>()

    • Add custom services to the DI container

    • Configure workflow options

  2. Apply(): Called after all features have been configured. Use this for:

    • Post-configuration tasks that depend on other features

    • Final validation

    • Complex initialization logic

UseXyz() Pattern

Elsa follows a convention where features are enabled using UseXyz() extension methods. This pattern:

  • Provides a fluent, discoverable API

  • Allows optional configuration via lambda expressions

  • Returns IModule for method chaining

Example:

builder.Services.AddElsa(elsa => elsa
    .UseMyFeature()
    .UseAnotherFeature(feature =>
    {
        // Configure the feature
    })
);

Activity Discovery & Registration

Elsa provides several methods for registering activities and workflows:

AddActivitiesFrom()

Scans the assembly containing type T and registers all classes marked with the [Activity] attribute:

Module.AddActivitiesFrom<MyFeature>();

This method:

  • Discovers all activity classes in the assembly

  • Registers them with the activity registry

  • Makes them available in the workflow designer

AddWorkflowsFrom()

Similar to AddActivitiesFrom<T>(), but registers workflow definitions:

Module.AddWorkflowsFrom<MyFeature>();

Creating a Custom Feature

Let's walk through creating a custom feature step by step.

Step 1: Define Your Feature Class

Create a class that inherits from FeatureBase:

using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;

namespace MyWorkflows.Features;

public class MyFeature : FeatureBase
{
    public MyFeature(IModule module) : base(module)
    {
    }

    public override void Configure()
    {
        // Configuration goes here
    }

    public override void Apply()
    {
        // Post-configuration goes here (optional)
    }
}

Key Points:

  • The constructor must accept IModule and pass it to the base class

  • Override Configure() to register services and components

  • Override Apply() only if you need post-configuration logic

Step 2: Configure Services

Inside the Configure() method, register your activities and services:

public override void Configure()
{
    // Register activities from this assembly
    Module.AddActivitiesFrom<MyFeature>();
    
    // Register custom services
    Services.AddSingleton<IMyCustomService, MyCustomService>();
    Services.AddScoped<IMyRepository, MyRepository>();
    
    // Configure workflow options
    Module.ConfigureWorkflowOptions(options =>
    {
        // Register UI hint handlers
        options.RegisterUIHintHandler<MyCustomUIHintHandler>("MyCustomHint");
    });
}

Available Registration Methods:

  • Module.AddActivitiesFrom<T>(): Register all activities in the assembly

  • Module.AddWorkflowsFrom<T>(): Register all workflow definitions in the assembly

  • Services.Add...(): Access the service collection directly for custom registrations

  • Module.ConfigureWorkflowOptions(): Configure workflow-specific settings

Step 3: Create Extension Methods

Create a static extension class with UseXyz() methods following the Elsa convention:

using Elsa.Features.Services;
using MyWorkflows.Features;

namespace MyWorkflows.Extensions;

public static class ModuleExtensions
{
    public static IModule UseMyFeature(
        this IModule module, 
        Action<MyFeature>? configure = null)
    {
        module.Use(configure);
        return module;
    }
}

Pattern with Options:

For more complex configuration, use an options class:

public static class ModuleExtensions
{
    public static IModule UseMyFeature(
        this IModule module, 
        Action<MyFeatureOptions>? configure = null)
    {
        return module.Use<MyFeature>(feature =>
        {
            if (configure != null)
            {
                var options = new MyFeatureOptions();
                configure(options);
                
                // Apply options to feature properties or services
                if (options.EnableAdvancedFeatures)
                {
                    feature.Services.AddSingleton<IAdvancedService, AdvancedService>();
                }
            }
        });
    }
}

public class MyFeatureOptions
{
    public bool EnableAdvancedFeatures { get; set; } = false;
    public string? ApiKey { get; set; }
    public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}

Step 4: Register Your Feature

In your application's Program.cs or Startup.cs, use your extension method:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddElsa(elsa => elsa
    .UseMyFeature()
    // Or with configuration:
    .UseMyFeature(options =>
    {
        options.EnableAdvancedFeatures = true;
        options.ApiKey = builder.Configuration["MyFeature:ApiKey"];
    })
);

Creating Custom Activities

Custom activities are the primary way to extend workflow functionality. See examples/SampleActivity.cs for a complete example.

Basic Activity Structure

Activities inherit from CodeActivity or CodeActivity<T> (for activities with outputs):

using Elsa.Workflows;
using Elsa.Workflows.Attributes;

[Activity("MyWorkflows", "Sample", "Description of what this activity does")]
public class SampleActivity : CodeActivity<string>
{
    protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
    {
        // Activity logic here
        
        // Set output if using CodeActivity<T>
        context.Set(Result, "output value");
        
        // Complete the activity
        await context.CompleteActivityAsync();
    }
}

Base Classes:

  • CodeActivity: For activities without a return value

  • CodeActivity<T>: For activities that produce a single output of type T

  • Activity: For more complex activities with custom behavior

Defining Inputs and Outputs

Use the [Input] and [Output] attributes to define activity ports:

[Activity("MyWorkflows", "Data", "Processes a message with optional prefix")]
public class ProcessMessage : CodeActivity<string>
{
    [Input(Description = "The message to process")]
    public Input<string> Message { get; set; } = default!;

    [Input(
        Description = "Optional prefix to prepend", 
        DefaultValue = "INFO")]
    public Input<string?> Prefix { get; set; } = default!;

    protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
    {
        var message = context.Get(Message);
        var prefix = context.Get(Prefix);
        
        var result = $"{prefix}: {message}";
        
        context.Set(Result, result);
        await context.CompleteActivityAsync();
    }
}

Input/Output Features:

  • Description: Shown in the workflow designer

  • DefaultValue: Default value if not specified

  • UIHint: Custom UI control for the property editor

  • Category: Groups related properties in the designer

Activity Attributes

The [Activity] attribute configures how the activity appears in the designer:

[Activity(
    Namespace = "MyCompany.MyProduct",    // Logical grouping
    Category = "Integration",              // Designer category
    Description = "Detailed description", // Shown in tooltips
    DisplayName = "My Activity"           // Display name (optional)
)]
public class MyActivity : CodeActivity
{
    // ...
}

Attribute Parameters:

  • Namespace: Groups activities logically (e.g., "MyCompany.Integration")

  • Category: Organizes activities in the designer toolbox

  • Description: Provides help text for workflow designers

  • DisplayName: Overrides the class name in the designer

Registering Activities

Activities are registered via features:

public override void Configure()
{
    // Registers all activities in the assembly containing MyFeature
    Module.AddActivitiesFrom<MyFeature>();
}

This scans for all types marked with [Activity] and registers them with the activity registry. They become immediately available in:

  • The workflow designer

  • Programmatic workflow definitions

  • The workflow execution engine

Packaging & Distribution

To share your custom modules, package them as NuGet packages:

1. Create a Class Library Project

dotnet new classlib -n MyWorkflows.Extensions
cd MyWorkflows.Extensions
dotnet add package Elsa
dotnet add package Elsa.Workflows.Core

2. Organize Your Code

MyWorkflows.Extensions/
├── Activities/
│   ├── SampleActivity.cs
│   └── AnotherActivity.cs
├── Features/
│   └── MyFeature.cs
├── Extensions/
│   └── ModuleExtensions.cs
└── MyWorkflows.Extensions.csproj

3. Configure the .csproj File

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PackageId>MyCompany.MyWorkflows.Extensions</PackageId>
    <Version>1.0.0</Version>
    <Authors>Your Name</Authors>
    <Description>Custom Elsa Workflows extensions</Description>
    <PackageTags>elsa;workflows;extensions</PackageTags>
    <RepositoryUrl>https://github.com/yourorg/yourrepo</RepositoryUrl>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Elsa" Version="3.0.*" />
    <PackageReference Include="Elsa.Workflows.Core" Version="3.0.*" />
  </ItemGroup>
</Project>

4. Build and Publish

dotnet pack -c Release
dotnet nuget push bin/Release/MyCompany.MyWorkflows.Extensions.1.0.0.nupkg \
  --api-key YOUR_API_KEY \
  --source https://api.nuget.org/v3/index.json

5. Consume the Package

Users can then install and use your package:

dotnet add package MyCompany.MyWorkflows.Extensions
builder.Services.AddElsa(elsa => elsa
    .UseMyFeature()
);

Advanced Topics

Custom UI Hint Handlers

UI hint handlers control how activity properties are edited in the workflow designer:

public class MyCustomUIHintHandler : IUIHintHandler
{
    public string UIHint => "MyCustomHint";

    public object GetDefaultValue()
    {
        return new MyCustomData();
    }
}

Register in your feature:

Module.ConfigureWorkflowOptions(options =>
{
    options.RegisterUIHintHandler<MyCustomUIHintHandler>("MyCustomHint");
});

Custom Serializers

For complex data types, implement custom serializers:

public class MyTypeSerializer : ISerializer
{
    public object Deserialize(string data)
    {
        // Deserialization logic
    }

    public string Serialize(object obj)
    {
        // Serialization logic
    }
}

Register in your feature:

Services.AddSingleton<ISerializer, MyTypeSerializer>();

Activity Execution Context

The ActivityExecutionContext provides access to:

  • Workflow Instance: Current workflow state and variables

  • Input/Output: Get and set activity inputs and outputs

  • Journal: Log custom data for debugging

  • Cancellation: Handle workflow cancellation

  • Services: Access dependency injection container

protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
    // Access workflow variables
    var workflowVar = context.GetWorkflowVariable<string>("MyVar");
    
    // Access services
    var myService = context.GetRequiredService<IMyService>();
    
    // Log to journal
    context.JournalData.Add("CustomKey", "CustomValue");
    
    // Check cancellation
    if (context.CancellationToken.IsCancellationRequested)
        return;
    
    // ... activity logic
}

Complete Examples

For complete, working examples, see:

Best Practices

  1. Follow Naming Conventions

    • Use UseXyz() for feature extension methods

    • Name features as XyzFeature

    • Use clear, descriptive activity names

  2. Provide Good Metadata

    • Use descriptive [Activity] attributes

    • Add meaningful descriptions to inputs/outputs

    • Include usage examples in XML comments

  3. Handle Errors Gracefully

    • Validate inputs in activities

    • Provide helpful error messages

    • Consider retry logic for transient failures

  4. Test Thoroughly

    • Unit test activities independently

    • Integration test features

    • Test with the workflow designer

  5. Document Your Extensions

    • Include XML documentation comments

    • Provide usage examples

    • Document configuration options

Further Reading

Support

For questions and support:

Last updated