Part 7 of Building Workflow Driven .NET Applications with Elsa 2

Putting it all together

Sipke Schoorstra
13 min readAug 8, 2021

In the previous part, we implemented a small number of custom activities that we will put to good use here.

In this part, we will be putting everything together by creating three workflows, one for each type of document:

  • Change Request Workflow
  • Leave Request Workflow
  • Identity Verification Workflow

The main purpose of setting up these workflows is to demonstrate how to deal with things like:

  • Passing information into the workflow
  • Passing information between activities

And as a bonus we will also see how to:

  • Add custom JavaScript functions
  • Add custom JavaScript Type definitions for intellisense
  • Handle the HTTP response when a user clicks an expired signal URL

Let’s get started!

Change Request Workflow

The Change Request workflow will be a simple, sequential workflow that looks like this:

Change Request Workflow

This workflow will:

  1. Load the document and file from the database.
  2. Compress the file.
  3. Send it as an attachment via email.
  4. Mark the document as Archived.

Workflow Settings

The first thing we do is configure the Tag property of the workflow and associate it with the Change Request document type ID:

Associate the Change Request workflow with the “ChangeRequest” document type ID.

Also be sure to give the workflow a proper name:

It’ a good idea to at least provide a name for the workflow.

Get Document

The first activity to add is the Get Document activity. When doing so, you get to provide the ID of the document to get.

As you may recall, we have a handler in our application called StartDocumentWorkflows which will execute any & all workflows that are tagged with the same document type ID as the created document .

When the matching workflows are invoked, we pass in the document ID as the correlation ID.

This means that we can simply provide this correlation ID as the value for the document ID to get using a simple JavaScript expression:

The `correlationId` JS variable contains the document ID value.

Later in the workflow, we will need to access the loaded document + file, so make sure to give this activity a name as well:

Named activities can be referenced from other activities.

Giving an activity a name allows us to access its output from other activities, as we will see next.

Zip File

Connect the Zip File activity and configure its Stream and File Name properties.

The Stream property should get the following JavaScript expression:


And the File Name property should get the following JavaScript expression:


As you type in the above expressions, notice that there is intellisense support to help you discover the activities that are available as well as their properties:

Activity output is supported by Intellisense.

Except for the Output property, which is of type DocumentFile . Intellisense doesn’t seem to pick up on that, showing any as its type instead.

To make intellisense become aware of custom .NET types, we need to register them explicitly. Let’s do that right away.

Ceate a new folder called JavaScript in the Scripting folder of the DocumentManagement.Workflows project and create the following file:

Then register this class with the service container from the ServiceCollectionExtensions class in the same project:

// Register custom type definition provider for JS intellisense.

Restart your application and refresh the designer. When you now start typing, intellisense will recognize the return type of the Output property:

Use custom type definition providers to add intellisense support for custom types

Before moving onto the next activity, be sure to give the Zip File activity a name of ZipFile1 .

Send Email

The Zip File activity produces a zipped stream as its output, which we can attach to an email and send it over.

Connect a new Send Email activity and provide the following settings:

  • To:
  • Subject: Please review this change request
  • Body: Please review attached change request.

For the Attachments field, we need to return one or more EmailAttachment objects. In this example, we will return just one attachment by entering the following JavaScript code:

const fileName = `${activities.GetDocument1.Output().Document.FileName}.zip`;const zipStream = activities.ZipFile1.Output();const contentType = contentTypeFromFileName(fileName);const attachment = new EmailAttachment(zipStream, fileName, contentType);return attachment;

In the above code snippet, we access output from both the Get Document and the Zip File activity.

We then instantiate a new EmailAttachment object that receives the zipped stream, the file name and the content type.

Archive Document

We end the workflow by connecting a new Archive Document activity with the following settings:

  • Document (JS): activities.GetDocument1.Output().Document

This activity will mark the document as archived and update it in the database.

Testing the Workflow

Make sure to publish your changes, then upload a new document with the Change Request selected as the document type.

After a few seconds, you should receive an email with a zip file containing the uploaded document:

The email contains one attachment: a zip file with the uploaded document.

That’s it for this workflow! The next one will be a long-running workflow where the user needs to click Approve or Reject before the workflow continues.

Leave Request Workflow

This workflow will look like this:

From Elsa Studio, create a new workflow named LeaveRequestWorkflow .

Get Document

Like the previous workflow, this one too shall start with the Get Document activity configured as:

  • Document ID (JS): correlationId
  • Name: GetDocument1

Send Email

Connect a new Send Email activity with the following settings:

  • To:
  • Subject: Please review this leave request

For the Attachments field, enter the following JavaScript code:

This looks similar to the Send Email’s Attachment script used in the previous workflow, except for a couple of differences:

  • Here, we will send the file itself, not a zipped version of it.
  • We determine the content type based on the file name using a function called contentTypeFromFileName .

This contentTypeFromFileName function however does not exist within Elsa. So let’s create it ourselves!

Within the JavaScript folder of the Scripting folder of the DocumentManagement.Workflows project, add the following class:

This is similar to what we did when we configured the Liquid templating engine. Only this time, we handle the EvaluatingJavaScriptExpression event so that we can register a custom function.

The handler depends on IContentTypeProvider, which is provided by the Microsoft.AspNetCore.StaticFiles packages, so let’s add it as a reference:

dotnet add DocumentManagement/src/DocumentManagement.Workflows/DocumentManagement.Workflows.csproj package Microsoft.AspNetCore.StaticFiles

Then update ServiceCollectionExtensions to register the service with an actual implementation:

services.AddSingleton<IContentTypeProvider, FileExtensionContentTypeProvider>();

Now we should be able to use the function, which conveniently returns the content type based on a given file name.

However, when we try it out in the expression editor, we don’t see the function suggested by intellisense:

Intellisense has no suggestions.

Although we can still use the function and type it in ourselves, let’s register the function so that it is easier to use next time.

To do this, we need to handle the RenderingTypeScriptDefinitions event, which gives us an opportunity to enrich the typescript definiton being generated.

Since we are providing the function in the `ConfigureJavaScriptEngineWithCustomFunctions` class, let’s have it also handle the RenderingTypeScriptDefinitions event:

public Task Handle(RenderingTypeScriptDefinitions notification, CancellationToken cancellationToken)
var output = notification.Output;

output.AppendLine("declare function contentTypeFromFileName(fileName: string): string");

return Task.CompletedTask;

The complete code should look like this:

This time around, we will see our custom function being available from intellisense:

Intellisense knows about our custom function.


Let’s move on to the Body field next.

Enter the following Liquid markup:

Contents of the Body field.

The Body field provides the recipient with two hyperlinks: an Approve and a Reject link. We generate the URLs for these links using the signal_url Liquid filter, which takes as an input the name of a signal and renders a fully-qualified URL that the user can invoke through their browser.

Invoking a signal URL will trigger the workflow that is blocked on the Signal Received activity that matches the same signal name.

We need our workflow to receive either an Approve or Reject signal from the user whom was sent an email with the leave request. A great activity to await user input is the Signal Received activity. However, we need two of them: one to listen for the Approve signal and another for the Reject signal.

If we were to put them one after another, then only one signal would be handled. Which would then cause the workflow to move on to the next Signal Received activity, putting the workflow in a suspended state again until the other signal is received.

In other words, that won’t work. How do we solve for that?


Meet the Fork activity.

The Fork activity can split workflow execution into zero or more branches. In our case, we want to split into an Approved and a Rejected branch.

Go ahead and connect a new Fork activity and configure it with the following branch names:

  • Branches: [Approved, Rejected]
A fork in the road.

When the Fork activity executes, it will schedule all activities that are connected to all of its branches. Which is exactly what we need.

Approved Branch

Connect a new Signal Received activity to the Approved outcome of the Fork activity and configure it as follows:

  • Signal: Approve

Next, connect a new Write Line activity with the following settings:

  • Text: Inform the requester that the leave request was approved.
  • Display Name: Approve

In real world scenarios, you will likely replace this activity with some form of communication to the person requesting their leave, or perhaps by requesting another review from a colleague. But for our purposes, it’s enough to do a simple write to console out.


We end the Approved branch with a Join activity, which will merge execution back into a single branch.

The Join activity has a Mode property that can be one of the following values:

  • Wait All
  • Wait Any

The Wait All mode will cause the workflow to remain at the Join activity until after ALL inbound branches have executed the Join activity.

The Wait Any mode on the other hand will continue as soon as ANY inbound branch executes the Join activity.

For our workflow, we need the user to either select Approve or Reject, and not both. Therefore, configure the Join activity as follows:

  • Mode: WaitAny

Before we continue with the activities that come after the Join, let’s return to the Rejected branch first.

Rejected Branch

This branch is similar to the Accepted branch in terms of structure. The only differences are the signal to listen for and the text to write to the console.

Connect a new Signal Received activity to the Approved outcome of the Fork activity and configure it as follows:

  • Signal: Reject

Next, connect a new Write Line activity with the following settings:

  • Text: Inform the requester that the leave request was denied.
  • Display Name: Reject

Now connect the Write Line activity to the Join activity we added earlier:

Press and hold SHIFT when clicking the source outcome. Then click the destination activity.

Get Document

Regardless of the user’s input (Accept or Reject), we want to archive the document.

However, since the workflow went out of memory when it was suspended (due to the Signal Received activities), the loaded document + file stream are no longer in memory. If you recall, we created the Get Document activity’s Output property to use Transient storage.

For this reason, we will add another Get Document activity to re-load the document into memory.

Configure it as follows:

  • Document ID: correlationId

Archive Document

Finally, connect a new Archive Document with the following settings:

  • Document (JS): input.Document

Notice that instead of referencing the previous Get Document activity directly, this time we take advantage of a JS variable called input .

In this context, input is a reference to the output provided by the previously executed activity. Which means that it references the DocumentFile object provided by the Get Document activity.

The only real difference is that intellisense doesn’t know at design time what the input might be, since it’s possible for multiple inbound activities to provide output.

Testing the Workflow

To test this workflow, upload another document but this time using the Leave Request option.

After a few seconds, you should receive an email with the uploaded document attached:

An email with the uploaded document was received.

Looking at the Workflow Instances screen, we indeed see a new workflow instance:

A new workflow instance was created.

When you click the workflow instance, you will be taken to the workflow instance viewer screen. The workflow instance viewer displays a journal representing the activities that have executed. In addition, the workflow designer displays a green border around each activity that has executed.

The workflow is currently blocked on two Signal Received activities.

As you can see, the workflow is currently blocked on two Signal Received activities: one awaiting the Approve signal, the other awaiting the Reject signal.

To send any of these signals, simply click one of the links received via the email.

When you click Approve, the console window of the application should display the following text:

Inform the requester that the leave request was approved.
The console window printed a line of text, showing that the Approved branch continued.

When you look at the workflow instance again, you will notice that it now has the Finished status.


One important area of improvement is the HTTP response we received when clicking the Approve or Reject link from the email. Right now, we get something like this:

Clicking Approve or Reject yields a default HTTP response.

This won’t do when we go to production of course. What we want to display the user instead is some kind of confirmation page.

Let’s fix this right away.

First, create a new Razor page in the web project named LeaveRequestApproved.cshtml and add the following content:

Create another Razor page named LeaveRequestDenied.cshtml and add the following content to it:

Now that we have these two pages in place, let’s consider how we can display these in response to the user clicking one of the two hyperlinks from the email.

The hyperlinks are generated using the signal_url Liquid filter, which points to an API controller exposed by Elsa. This controller is responsible for decoding the signal token and then invoking the appropriate workflow.

What it also does is publish an event. Which is very convenient, because we can handle this event and write a custom HTTP response. In our case, we can simply send an HTTP 302 — Redirect response that redirects the browser to the appropriate Razor page.

To do this, create a new folder called Handlers in the web project and add the following class:

To get this handler registered with DI, add the following code to Startup:

// Register all Mediatr event handlers from this assembly.

When we now start the application again, upload a new document and click on Approve, we get the following result:

We now see a user-friendly page upon clicking Approve.

That’s it for this workflow!

Identity Verification Workflow

The Identity Verification workflow will look like this:

The Identity Verification workflow.

Since this workflow is almost identical to the previous workflow, I will not go over the entire workflow in details. But I will point out two things:

  1. Make sure to tag this workflow with the IdentityVerification document type ID.
  2. Configure the Update Blockchain activity as follows:
  • File: input.FileStream

Remember: the previously executed activity (Get Document) will have its output be provided as input into the next activity (Update Blockchain) automatically, which means it’s available via the input JS variable.

By convention, if an activity output property is named Output , it will be available to the next activity via the input JS variable and the Input liquid variable.


This concludes the blog series about building workflow-driven ASP.NET applications with Elsa 2.

I hope you found it useful and that you see how easy and fun it is to power your applications with workflows.

Having workflow-driven apps is a powerful feat. As your set of activities, JS and Liquid building blocks grow, adding and updating business rules or even entire processes becomes so much quicker and easier.

And you always have the choice of whether these workflows are provided from the database, allowing changes to be made in production systems, or to have them provided as part of your application’s code base in the form of JSON files.

And that’s what Elsa is really all about: to offer a wide range of options to the developer when it comes to enabling workflows in their applications.

Here, we’ve seen how to empower an ASP.NET application with workflow capabilities. Another approach might have been to have a separate ASP.NET application acting as the workflow server. The Razor app’s backend would then communicate with the workflow server via Elsa API endpoints and possibly with custom API endpoints as well, which internally leverage the Elsa library. Although not discussed in this series, Elsa also supports conductor-style workflows using generic Task and Event activities. The Task activity represents a task to be executed by the client app (e.g. the Razor app), while the Event activity represents an event raised by the client app, causing the workflow on the workflow server to resume execution.

This setup works very wel in a microservice architecture.

Anyway. I could go on and on about all the great things you can do with Elsa Workflows, but I will save all that for future posts.

Stay awesome!