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:
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
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:
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:
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:
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)
-
Open ELSA Studio at
http://localhost:13000. -
Go to Workflows → Definitions and click CREATE WORKFLOW.
-
Drag an HTTP Endpoint activity onto the canvas – this will be the trigger.
-
Configure its Path (e.g.,
/hello) and Method (e.g.,GET). -
Connect a WriteLine activity after it and set its Text to
"Hello from HTTP!". -
Connect a HTTP Response activity after the WriteLine.
-
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
DispatchWorkflowis “fire‑and‑forget”. If you need to wait for the child to finish, useRunWorkflowinstead.
Part 10: Advanced – Custom Activities
Example 11: Building Your Own Activity
ELSA is highly extensible – you can create custom activities easily:
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:
builder.Services.AddElsa(options => options .AddActivity<AddActivity>() // ... other configurations );
Use it in a workflow:
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:
// 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:
dotnet ef migrations add InitialCreate
dotnet ef database update
Example 13: MongoDB Persistence
// 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
[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:
[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:
IWorkflowRunnerruns synchronously (good for testing).IWorkflowDispatcherruns 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
-
📖 Official Docs: docs.elsaworkflows.io
-
🐙 GitHub: github.com/elsa-workflows/elsa-core
-
🎨 ELSA Studio: Visual designer for drag‑and‑drop workflow design
-
🐳 Docker Image: Quick start with ELSA Server + Studio
Best Practices Reminder
-
Always configure persistence in production – otherwise state is lost on restart.
-
Long‑running workflows must use persistence and dispatching – otherwise timers and resumptions won’t work.
-
Prefer asynchronous dispatch over synchronous run for better throughput.
-
Use variables wisely – distinguish workflow‑level from transient variables.
-
Leverage ELSA Studio – let non‑developers also participate in process design.