From Zero to Hero with ELSA Workflow

From Zero to Hero with ELSA Workflow: A Complete .NET Workflow Development Guide

Preface: What Is Workflow Development?

Before we dive in, let’s clarify a fundamental question: what exactly is workflow development?

Imagine you are processing an order approval flow:
Customer places order → Inventory check → Manager approval → Shipping notification.
This process involves multiple steps, may require different people and systems, and has a clear order and conditional branches. In software development, we call such a sequence of steps (called “activities”) that represents a business process a workflow.

The core idea of workflow development is separating business logic from application code. Instead of hard‑coding each step, you define the process in a more intuitive way – visually or declaratively. The benefits are huge:

  • Business people can understand and modify the process directly.

  • When the process changes, you don’t need to recompile and redeploy the whole application.

  • Complex flows become manageable and auditable.

ELSA Workflow is one of the best‑in‑class open‑source workflow engines for the .NET ecosystem – free (MIT License), feature‑rich, and production‑ready.


Part 1: What Is ELSA Workflow?

ELSA Workflow is a set of .NET libraries that lets you add workflow capabilities to your .NET applications. Think of it as Lego bricks for building your own workflow engine.

Key Features at a Glance

Feature Description
Multiple definition styles Define workflows in C#, JSON, or with a visual designer.
Short‑ and long‑running Supports workflows that finish in milliseconds or span days/years.
Rich activity library Many built‑in activities ready to use.
Trigger mechanism Start workflows based on events or conditions.
Dynamic expressions Use C#, JavaScript, or Liquid expressions.
Persistence agnostic Works with EF Core, MongoDB, Dapper, and more.
Visual designer Drag‑and‑drop web‑based designer (ELSA Studio).

Part 2: Core Concepts – Quick Overview

Learn these key terms before you start coding:

🧩 Activity

The smallest executable unit – each activity represents one concrete action, e.g., WriteLine prints text, Delay waits for a time.

📋 Workflow

A sequence of activities that defines a complete business process. In ELSA, workflows are represented by the Workflow class.

🔖 Bookmark

A pause point in the workflow. When an activity needs to wait for an external event (e.g., user approval, HTTP callback), it creates a bookmark and suspends execution until resumed.

🚀 Trigger

A special activity that starts new workflow instances. For example, HttpEndpoint starts a workflow when it receives an HTTP request.

🔗 Connection

Defines the transition between activities – after one completes, which one runs next.

📊 Variable

Used to store and pass data during workflow execution.


Part 3: Setting Up Your Environment

3.1 Quickest Way – Use Docker

ELSA provides a ready‑to‑run Docker image that includes both the ELSA Server and the ELSA Studio designer:

bash
docker pull elsaworkflows/elsa-server-and-studio-v3:latest
docker run -t -i -e ASPNETCORE_ENVIRONMENT='Development' -e HTTP_PORTS=8080 -e HTTP__BASEURL=http://localhost:13000 -p 13000:8080 elsaworkflows/elsa-server-and-studio-v3:latest

After startup, open http://localhost:13000 and log in with admin / password.

⚠️ Note: This image is for development and evaluation only – do not use in production.

3.2 Integrate into an Existing .NET Project

If you prefer to embed ELSA in your own ASP.NET Core application, follow these steps:

Step 1: Install NuGet packages

bash
dotnet add package Elsa

Additional packages you may need:

  • Elsa.EntityFrameworkCore – for EF Core persistence

  • Elsa.MongoDb – for MongoDB persistence

  • Elsa.Http – HTTP activities

  • Elsa.Studio – for the visual designer (if you host it)

Step 2: Configure services

In your Program.cs:

csharp
builder.Services.AddElsa(options => options
    .UseEntityFrameworkPersistence(ef => ef.UseSqlite("Data Source=elsa.db"))
    .AddConsoleActivities()
    .AddHttpActivities()
    .AddWorkflowsFrom<Program>());

app.UseWorkflowsApi();
app.UseWorkflows();

Part 4: Your First Workflow (Basic)

Example 1: Hello World (Pure Code)

The simplest possible workflow – prints a greeting:

csharp
using Elsa.Workflows;
using Elsa.Workflows.Activities;

public class HelloWorldWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        builder
            .StartWith<WriteLine>(x => x.Text = new("Hello World from ELSA!"))
            .Then<WriteLine>(x => x.Text = new("Welcome to workflow development!"));
    }
}

This workflow has two sequential steps – both print messages.

Example 2: Using Sequence

You can also use the Sequence activity for a more declarative style:

csharp
using Elsa.Workflows;
using Elsa.Workflows.Activities;

var workflow = new Sequence
{
    Activities =
    {
        new WriteLine("Hello World!"),
        new Delay(TimeSpan.FromSeconds(1)),
        new WriteLine("It is nice to meet you!")
    }
};

Example 3: Using an HTTP Trigger (Visual Designer)

  1. Open ELSA Studio at http://localhost:13000.

  2. Go to Workflows → Definitions and click CREATE WORKFLOW.

  3. Drag an HTTP Endpoint activity onto the canvas – this will be the trigger.

  4. Configure its Path (e.g., /hello) and Method (e.g., GET).

  5. Connect a WriteLine activity after it and set its Text to "Hello from HTTP!".

  6. Connect a HTTP Response activity after the WriteLine.

  7. Click the green Run button to test.

After publishing, visit http://localhost:13000/api/workflows/hello to trigger the workflow.


Part 5: Intermediate – Variables and Data Passing

Example 4: Using Workflow Variables

Variables allow you to store and share data across activities:

csharp
public class VariableDemoWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        var nameVariable = new Variable<string>();
        
        builder
            .StartWith<SetVariable<string>>(x =>
            {
                x.Variable = new(nameVariable);
                x.Value = new("Alice");
            })
            .Then<WriteLine>(x =>
                x.Text = new($"Hello, {nameVariable}!"))
            .Then<SetVariable<string>>(x =>
            {
                x.Variable = new(nameVariable);
                x.Value = new("Bob");
            })
            .Then<WriteLine>(x =>
                x.Text = new($"Goodbye, {nameVariable}!"));
    }
}

Example 5: Using Activity Outputs

Activities can produce output that subsequent activities consume:

csharp
public class OutputDemoWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        var resultVariable = new Variable<int>();
        
        builder
            .StartWith<CalculateActivity>(x =>
            {
                x.Result = new(resultVariable);
            })
            .Then<WriteLine>(x =>
                x.Text = new($"The result is: {resultVariable}"));
    }
}

Part 6: Intermediate – Conditional Branching

Example 6: Using the Decision Activity

Decision works like an if statement – it chooses a branch based on a condition:

csharp
public class DecisionWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        var scoreVariable = new Variable<int>();
        
        builder
            .StartWith<SetVariable<int>>(x =>
            {
                x.Variable = new(scoreVariable);
                x.Value = new(85);
            })
            .Then<Decision>(x =>
            {
                x.Condition = new($"return {scoreVariable} >= 60;");
            })
            .When(Outcome.True)
            .Then<WriteLine>(x => x.Text = new("Congratulations, you passed!"))
            .When(Outcome.False)
            .Then<WriteLine>(x => x.Text = new("Sorry, you failed."));
    }
}

Example 7: Using the Switch Activity

Switch works like a switch statement, supporting multiple cases:

csharp
public class SwitchWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        var roleVariable = new Variable<string>();
        
        builder
            .StartWith<SetVariable<string>>(x =>
            {
                x.Variable = new(roleVariable);
                x.Value = new("admin");
            })
            .Then<Switch>(x =>
            {
                x.Value = new(roleVariable);
                x.Cases.Add("admin", new SwitchCase { Label = "Admin" });
                x.Cases.Add("user", new SwitchCase { Label = "User" });
                x.Cases.Add("guest", new SwitchCase { Label = "Guest" });
            })
            .When("Admin")
            .Then<WriteLine>(x => x.Text = new("Welcome Admin! Full access."))
            .When("User")
            .Then<WriteLine>(x => x.Text = new("Welcome User! Limited access."))
            .When("Guest")
            .Then<WriteLine>(x => x.Text = new("Welcome Guest! Read‑only access."));
    }
}

Part 7: Intermediate – Parallel Execution

Example 8: Using Fork and Join

Fork splits the workflow into parallel branches, and Join waits for all to complete:

csharp
public class ForkJoinWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        builder
            .StartWith<Fork>(x =>
            {
                x.Branches = new[] { "Branch1", "Branch2", "Branch3" };
            })
            .When("Branch1")
            .Then<WriteLine>(x => x.Text = new("Branch 1 started..."))
            .Then<Delay>(x => x.Duration = new(TimeSpan.FromSeconds(2)))
            .Then<WriteLine>(x => x.Text = new("Branch 1 done!"))
            .When("Branch2")
            .Then<WriteLine>(x => x.Text = new("Branch 2 started..."))
            .Then<Delay>(x => x.Duration = new(TimeSpan.FromSeconds(1)))
            .Then<WriteLine>(x => x.Text = new("Branch 2 done!"))
            .When("Branch3")
            .Then<WriteLine>(x => x.Text = new("Branch 3 started..."))
            .Then<WriteLine>(x => x.Text = new("Branch 3 done!"))
            .Join()
            .Then<WriteLine>(x => x.Text = new("All branches completed!"));
    }
}

Part 8: Advanced – Long‑Running Workflows and Bookmarks

Example 9: Waiting for an Event

Long‑running workflows pause and wait for external events:

csharp
public class ApprovalWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        var approvalVariable = new Variable<bool>();
        
        builder
            .StartWith<WriteLine>(x => x.Text = new("Waiting for approval..."))
            .Then<Event>(x => x.EventName = new("Approval"))
            .Then<ReadEvent>(x =>
            {
                x.EventName = new("Approval");
                x.Output = new(approvalVariable);
            })
            .Then<Decision>(x =>
                x.Condition = new($"return {approvalVariable} == true;"))
            .When(Outcome.True)
            .Then<WriteLine>(x => x.Text = new("✅ Approved!"))
            .When(Outcome.False)
            .Then<WriteLine>(x => x.Text = new("❌ Rejected!"));
    }
}

How to trigger the event from the outside:

csharp
await eventPublisher.PublishAsync(
    new WorkflowEvent("Approval", workflowInstanceId)
    {
        Input = new Dictionary<string, object>
        {
            ["ApprovalResult"] = true   // or false
        }
    }
);

Part 9: Advanced – Child Workflows

Example 10: Dispatching a Sub‑Workflow

Use DispatchWorkflow to start another workflow from within a parent:

Child workflow:

csharp
public class ChildWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        var inputVariable = new Variable<string>();
        
        builder
            .StartWith<WriteLine>(x =>
                x.Text = new($"Child received: {inputVariable}"))
            .Then<WriteLine>(x => x.Text = new("Child finished."));
    }
}

Parent workflow:

csharp
public class ParentWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        builder
            .StartWith<WriteLine>(x => x.Text = new("Parent started..."))
            .Then<DispatchWorkflow>(x =>
            {
                x.WorkflowDefinitionId = new("ChildWorkflow");
                x.Input = new Dictionary<string, object>
                {
                    ["Input"] = "Data from parent"
                };
            })
            .Then<WriteLine>(x => x.Text = new("Child dispatched. Parent continues."));
    }
}

💡 Tip: DispatchWorkflow is “fire‑and‑forget”. If you need to wait for the child to finish, use RunWorkflow instead.


Part 10: Advanced – Custom Activities

Example 11: Building Your Own Activity

ELSA is highly extensible – you can create custom activities easily:

csharp
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;

[Activity("Custom", "Math", "A simple addition calculator")]
public class AddActivity : Activity
{
    [Input(Description = "First number")]
    public Input<int> Number1 { get; set; } = new();

    [Input(Description = "Second number")]
    public Input<int> Number2 { get; set; } = new();

    [Output(Description = "Sum of the two numbers")]
    public Output<int> Result { get; set; } = new();

    protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
    {
        var num1 = Number1.Get(context);
        var num2 = Number2.Get(context);
        var sum = num1 + num2;
        
        context.Set(Result, sum);
        context.Log($"Calculated: {num1} + {num2} = {sum}");
        
        await Task.CompletedTask;
    }
}

Register the custom activity:

csharp
builder.Services.AddElsa(options => options
    .AddActivity<AddActivity>()
    // ... other configurations
);

Use it in a workflow:

csharp
public class CustomActivityWorkflow : IWorkflow
{
    public void Build(IWorkflowBuilder builder)
    {
        var resultVariable = new Variable<int>();
        
        builder
            .StartWith<AddActivity>(x =>
            {
                x.Number1 = new(10);
                x.Number2 = new(20);
                x.Result = new(resultVariable);
            })
            .Then<WriteLine>(x =>
                x.Text = new($"10 + 20 = {resultVariable}"));
    }
}

Part 11: Advanced – Persistence Configuration

Example 12: EF Core Persistence

To survive application restarts, you need to persist workflow state:

csharp
// Install: dotnet add package Elsa.EntityFrameworkCore
// Install: dotnet add package Microsoft.EntityFrameworkCore.Sqlite

builder.Services.AddElsa(options => options
    .UseEntityFrameworkPersistence(ef => ef
        .UseSqlite("Data Source=elsa.db"))
    .AddConsoleActivities()
    .AddHttpActivities()
    .AddWorkflowsFrom<Program>());

Run migrations:

bash
dotnet ef migrations add InitialCreate
dotnet ef database update

Example 13: MongoDB Persistence

csharp
// dotnet add package Elsa.Persistence.MongoDb

builder.Services.AddElsa(options => options
    .UseMongoDbPersistence(mongo => mongo
        .WithConnectionString("mongodb://localhost:27017")
        .WithDatabaseName("ElsaWorkflows"))
    .AddConsoleActivities()
    .AddWorkflowsFrom<Program>());

Part 12: Execution and Dispatching

Example 14: Synchronous Execution

csharp
[ApiController]
[Route("api/workflows")]
public class WorkflowController : ControllerBase
{
    private readonly IWorkflowRunner _workflowRunner;

    public WorkflowController(IWorkflowRunner workflowRunner)
    {
        _workflowRunner = workflowRunner;
    }

    [HttpPost("run")]
    public async Task<IActionResult> RunWorkflow([FromBody] RunWorkflowRequest request)
    {
        var result = await _workflowRunner.RunAsync(request);
        return Ok(result);
    }
}

Example 15: Asynchronous Dispatch

For production, use the dispatcher to queue executions:

csharp
[ApiController]
[Route("api/workflows")]
public class WorkflowController : ControllerBase
{
    private readonly IWorkflowDispatcher _workflowDispatcher;

    public WorkflowController(IWorkflowDispatcher workflowDispatcher)
    {
        _workflowDispatcher = workflowDispatcher;
    }

    [HttpPost("dispatch")]
    public async Task<IActionResult> DispatchWorkflow([FromBody] DispatchWorkflowDefinitionRequest request)
    {
        await _workflowDispatcher.DispatchAsync(request);
        return Accepted("Workflow queued for execution");
    }
}

💡 Key difference: IWorkflowRunner runs synchronously (good for testing). IWorkflowDispatcher runs asynchronously via a queue – preferred for production.


Summary and Resources

Suggested Learning Path

Phase Topics Time
Beginner Hello World, Sequence, WriteLine 1‑2 days
Intermediate Variables, data passing, HTTP triggers 3‑5 days
Advanced Conditionals, parallel execution, long‑running 1‑2 weeks
Expert Custom activities, child workflows, persistence 2‑4 weeks

Recommended Resources

Best Practices Reminder

  1. Always configure persistence in production – otherwise state is lost on restart.

  2. Long‑running workflows must use persistence and dispatching – otherwise timers and resumptions won’t work.

  3. Prefer asynchronous dispatch over synchronous run for better throughput.

  4. Use variables wisely – distinguish workflow‑level from transient variables.

  5. Leverage ELSA Studio – let non‑developers also participate in process design.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *