Coding a workflow

Introduction

We provide an SDK, Nexus.Link.WorkflowEngine.Sdk, which enables C# programmers to develop asynchronous workflows. It saves its state in a database that you have to provide and it uses AsyncManager for sending requests asynchronously.

This tutorial tries to guide you to get started with your first workflow. We base the tutorial on the same example as the rest of the documentation; a workflow for reporting employee planned absence. You can find the code for this tutorial in a public repo; workflow-examples, and this is Example1 in that repo.

Setup your application

Create a Web API project

Start by creating a new Web API project in Visual Studio. We have used the following settings in the example project:

<PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

Dependency inject Persistence

When running your workflow in a production environment, you would use an SQL Server database for persisting the state of the workflow and maybe an Azure Storage for fallback persistence. In this example, we will use a memory implementation, which is provided in the NuGet Nexus.Link.WorkflowEngine.Sdk.Persistence.Memory.

There are two groups of tables in the database; configuration tables that keep information about the existing workflow definitions, their different implementation versions, and the activities that they consist of.

// (1) Setup persistencs. We use a memory implementation instead of SQL Server
builder.Services.TryAddSingleton<IConfigurationTables>(provider => new ConfigurationTablesMemory());
builder.Services.TryAddSingleton<IRuntimeTables>(provider => new RuntimeTablesMemory());
// This prepares for the special security solution for intracommunication from AM to WFE
var hashTable = new HashTableMemory();

Setup internal HTTP client

The workflow engine will redirect all outgoing calls to Async Manager. We need to setup that. In a production environment, you would setup that by defining how your program can access the AsyncManager service in Azure. In this example, we will use a mockup library of that service, provided in the NuGet library Nexus.Link.Capabilities.AsyncRequestMgmt.Mock.

We will set the timeout for the HTTP client to 1 hour to not be interrupted when we step through the code.

// (2) Setup Async Manager. We use a mock version of Async Manager.
var workflowEngineInternalHttpSender = new HttpSender("");
workflowEngineInternalHttpSender.HttpClient.ActualHttpClient.Timeout = TimeSpan.FromHours(1); // Default is 100 seconds. We need more time when we debug.
var asyncRequestMgmtCapability = new AsyncRequestMgmtMock(workflowEngineInternalHttpSender);
builder.Services.TryAddSingleton<IAsyncRequestMgmtCapability>(asyncRequestMgmtCapability); 
// (XX) Set up the HTTP client that our workflow should use for all outgoing requests
var handlers = Nexus.Link.WorkflowEngine.Sdk.Outbound.OutboundPipeFactory.CreateDelegatingHandlers(asyncRequestMgmtCapability);
var httpClientWithNexusLinkSupport = HttpClientFactory.Create(handlers);
var httpSender = new HttpSender("https://localhost:7010/")
{
    HttpClient = new HttpClientWrapper(httpClientWithNexusLinkSupport)
};
httpSender.HttpClient.ActualHttpClient.Timeout = TimeSpan.FromHours(1); // Default is 100 seconds. We need more time when we debug.

Dependency inject WorkflowEngine

The following injection adds the final things that the workflow engine needs, provided in the NuGet library Nexus.Link.WorkflowEngine.Sdk.

// (3) Set up required capabilities
builder.Services.TryAddSingleton<IWorkflowEngineRequiredCapabilities, WorkflowCapabilities>();

Setup Nexus Link middleware

We need to setup the Nexus Link middleware that is provided in Nexus.Link.Misc.AspNet.Sdk.

To be more precise; we need to know which workflow instance that we are currently working with (5), we need outgoing requests to be redirected to AsyncManager (6), we expect non-OK HTTP responses to be converted to C# exceptions (7) and we want AsyncManager to have a backdoor into workflow engine.

// (4) Setup nexus link middleware. NOTE! Use the one from Nexus.Link.Misc.AspNet.Sdk
var linkOptions = new NexusLinkMiddlewareOptions
{
    Features = new Nexus.Link.Misc.AspNet.Sdk.Inbound.Options.MiddlewareFeatures
    {
        // (5) This is how workflow engine knows which workflow that should be waken up and fast forwarded
        SaveExecutionId = { Enabled = true },
        // (6) We must setup redirection of requests to AM 
        RedirectAsynchronousRequests =
        {
            Enabled = true,
            RequestService = asyncRequestMgmtCapability!.Request,
            ReentryAuthenticationService = new ReentryAuthenticationService(new HashService(hashTable))
        },
        // (7) We expect the non-OK HTTP responses to be converted to exceptions
        ConvertExceptionToHttpResponse = { Enabled = true },
        // (8) We want async manager to have a backdoor into workflow engine
        SaveReentryAuthentication = { Enabled = true},
        // These are optional, but common in Nexus Link projects
        SaveCorrelationId = { Enabled = true },
        LogRequestAndResponse = { Enabled = true },
    }
};
app.UseNexusLinkMiddleware(linkOptions);

Setup JSON serialization

// (9) Setup JSON serialization
JsonConvert.DefaultSettings = () =>
{
    var settings = new JsonSerializerSettings().SetAsNexusLink();
    return settings;
};

Setup your workflow

You implement each version of your workflow as classes that inherits from WorkflowImplementation. You also add a class to manage them and that class inherits from WorkflowContainer.

The code examples below are based on the same example as in the article Modeling a workflow.

The workflow container

The workflow container defines which activities that the implementations consist of. You also define all workflow implementations (workflow versions) here.

First, you need a class with a constructor.

The class should inherit from WorkflowContainer (1). It must have a parameter for IWorkflowEngineRequiredCapabilities (2) because the base class requires it. You should also have parameters for the activity business logic for each of the implementations. In the example, we only have one version (3). When you call the base class you define the name of the capability, the name of the workflow, and a unique GUID for this workflow (4).

When it comes to the activity business logic, just create an empty interface for now. We will fill it in later.

/// <summary>
/// The workflow container for the workflow "Update abscence"
/// </summary>
public class UpdateAbsenceContainer
    // (1) Inherit from WorkflowContainer
    : WorkflowContainer
{
    /// <inheritdoc />
    public UpdateAbsenceContainer(
        // (2) Functionality that the workflow engine needs
        IWorkflowEngineRequiredCapabilities workflowCapabilities,
        // (3) Parameters with the workflow logic; one for each of the different workflow versions
        Abstract.Capabilities.WorkforceMgmt.Workflows.Version1.IUpdateAbsenceLogic logicV1)
        // (4) Define the name of the capability, the name of the workflow, the id of the workflow
        : base("Workforce Management", "Update absence", "C88E5A87-627E-4409-A6F6-B2B26F18A26F", workflowCapabilities)
    {
    }
}

Next we will define a constant for each of the activities that we are going to use:

public class UpdateAbsenceContainer : WorkflowContainer
{
   ...
    /// <summary>
    /// (4 a) Constants for the activities
    /// </summary>
    public static class Activities
    {
        public const string Lock = "88FA7773-D08D-4E46-8E35-27A2592B3224";
        public const string GetStaffMembers = "81AA9F2A-6E18-46DC-96C5-F1857DDD5519";
        public const string ForEachStaffMember = "F84DCFD1-EB9D-412C-B200-CDCED4D4D1EE";
        public const string RequestStaffMemberToReportAbscence = "26FA1940-0221-4DC0-BD1F-7F6D7F87F3C9";
        public const string ReadAbsenceRequests = "08939ED0-1361-493C-A118-4D86348E81EF";
        public const string ForEachRequest = "DDA8B0D4-57CA-4330-904A-E7C45D247FAF";
        public const string AddToWorkforceCalendar = "6B8FAD61-75E3-4281-A88C-F09050BEC7F8";
    }
}

Then in the constructor, we will add a definition for each where we give the activity a name and specify the type of the activity.

{
      ...
        //
        // (4 b) Define the activities
        // 

        // New activities in version 1
        DefineActivity(Activities.Lock, "Lock", ActivityTypeEnum.Action);
        DefineActivity(Activities.GetStaffMembers, "Get staff members", ActivityTypeEnum.Action);
        DefineActivity(Activities.ForEachStaffMember, "For each staff member", ActivityTypeEnum.ForEachParallel);
        DefineActivity(Activities.RequestStaffMemberToReportAbscence, "Ask staff member to report absence", ActivityTypeEnum.Action);
        DefineActivity(Activities.ReadAbsenceRequests, "Read absence requests", ActivityTypeEnum.Action);
        DefineActivity(Activities.ForEachRequest, "For each request", ActivityTypeEnum.ForEachParallel);
        DefineActivity(Activities.AddToWorkforceCalendar, "Add to workforce calendar", ActivityTypeEnum.Action);
    }

Finally, you need to add all your implementations. To do this, you would first need to create the implementation class, see the next subchapter.

{
        //
        // (5) Add all the implementations (one for each workflow version that needs to be available at the same time)
        //

        AddImplementation(new Version1.UpdateAbsenceImplementation(this, logicV1));

        ...
    }

Boiler plate code for an implementation

The workflow implementation is where the real programming is done. You implement one class for each new version of the workflow.

The class should inherit from WorkflowImplementation (1). It must have a parameter for IWorkflowContainer (2) because the base class requires it. When you call the base class you must specify which the major version number and minor version number is for this implementation (4). The minor version can be increased without making a new implementation copy when you make minor changes. For changes that affect old running workflows, you should make a new implementation copy, where you increase the major version and set the minor version to 0.

If the workflow has any in parameters you need to define them

/// <summary>
/// The implementation of version 1 of "Update Abscence".
/// </summary>
public class UpdateAbsenceImplementation
    // (1) Inherit from WorkflowImplementation
    : WorkflowImplementation
{
    private readonly IUpdateAbsenceLogic _logic;

    /// <summary>
    /// (5 b) Constants for the parameters
    /// </summary>
    public static class WorkflowParameters
    {
        public const string DepartmentId = "departmentId";
        public const string Deadline = "deadline";
    }
    
     /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="workflowContainer">(2) The workflow container for this workflow.</param>
    /// <param name="logic">(3) The activity business logic that we will use in the activites.</param>
    public UpdateAbsenceImplementation(
        IWorkflowContainer workflowContainer, 
        IUpdateAbsenceLogic logic)
        // (4) Specify the major version and the minor version
        : base(1, 0, workflowContainer)
    {
        _logic = logic;

        // (5 a) Define in parameters
        DefineParameter<string>(WorkflowParameters.DepartmentId);
        DefineParameter<DateTimeOffset>(WorkflowParameters.Deadline);
        
        ...
    }
}

You can control what should be logged into the database and some other features of your workflow. For now, we just set up some good initial values (6).

// (6) Set some workflow options
if (FulcrumApplication.IsInDevelopment)
{
    // Set values that makes it easier to debug the workflow
    SetDebugMode();
}
else
{
    // Set the default activity options
    DefaultActivityOptions.LogCreateThreshold = LogSeverityLevel.Verbose;
    DefaultActivityOptions.LogPurgeStrategy = LogPurgeStrategyEnum.AfterActivitySuccess;
    DefaultActivityOptions.LogPurgeThreshold = LogSeverityLevel.Warning;
}

That was all the code that goes into the controller. Now we just have to add a workflow instance title (7) and a factory method (8), and we are finally ready to implement the workflow logic.

// (7) The unique title for a workflow instance. You might want to use the parameters here to make the instance title more unique
/// <inheritdoc />
public override string GetInstanceTitle()
{
        var departmentId = GetWorkflowArgument<string>(WorkflowParameters.DepartmentId);
        return $"{WorkflowContainer.WorkflowFormTitle} {WorkflowParameters.DepartmentId}: {departmentId}";
}

// (8) A factory method to create new instances.
/// <inheritdoc />
public override IWorkflowImplementation CreateWorkflowInstance() => new UpdateAbsenceImplementation(WorkflowContainer, _logic);

Prepare for workflow logic

The entry point for the workflow logic is the method ExecuteWorkflowAsync (9). To make debugging easier, we often ignore the incoming cancellation token when we are in development mode (10).

// (9) The entry point for the workflow logic
/// <inheritdoc />
public override async Task ExecuteWorkflowAsync(CancellationToken cancellationToken = default)
{
    // (10) When debugging, we don't want the operation to be cancelled due to time out in the cancellation token.
    if (FulcrumApplication.IsInDevelopment)
    {
        cancellationToken = default;
    }

    // This is where we put the actual workflow logic
    ...
}

Implementing the workflow logic

The business logic is programmed to be very similar to the BPMN 2.0 diagram. Keep the article Modeling a workflow at hand to follow how we activity by activity build up the workflow logic.

1. Lock

The example starts off with locking the rest of the activities. The rationale behind this is that we don't want this workflow to run several instances simultaneously.

// (11) Get one of the parameters
var departmentId = GetWorkflowArgument<string>(WorkflowParameters.DepartmentId);

// (12) We will call CreateActivity() once for every activity in the BPMN 2.0 diagram.
await CreateActivity(1, UpdateAbsenceContainer.Activities.Lock)
    // (13) This activity requires a lock over all its activities,
    // so no one can start another simultaneous instance for the same department
    // before this instance has finished.
    .Lock(departmentId)
    // (14) When we successfully have the lock, we will call Activity_1_Async
    .Then(DoUnderLockAsync)
    // (15) If we didn't get the lock, we will be put in a queue and the optional Else code will run.
    .Else(_ => throw new WorkflowFailedException(
        ActivityExceptionCategoryEnum.BusinessError,
        $"The workflow was already locked for department {departmentId}.",
        $"Method already running."))
    // (16) This is where the activity logic is actually executed
    .ExecuteAsync(cancellationToken);
  1. Get the departmentId parameter, which we will use as the lock id (11).
  2. We create the activity, give it sequence number 1, and use one of the activity constants in the workflow container to specify which activity this is (12).
  3. Now we define which kind of activity this is. In this case a Lock activity. We specify that we want to lock on the departmentId. This means that two calls with different departmentId will not affect each other, but calls with the same departmentId can't run simultaneously.
    When we execute this activity, it can either succeed in getting a lock or it will be put in a queue, waiting for the lock to be released (13).
  4. When we eventually get hold of the lock this specifies what to do next (14).
  5. If the lock was already taken, then this code will be activated, i.e. we will throw an exception and the workflow will be terminated (15).
  6. All the steps above were just the configuration for this activity. The ExecuteAsync statement is what actually executes the activity (16).

1.1 Get staff members

At (14) above we call DoUnderLock if we were successful in getting the lock. That method covers two activities, just like the model example. We will start off by looking at the first activity; Get staff members.

private async Task DoUnderLock(IActivityLock activity, CancellationToken cancellationtoken)
{
   // (17) Get staff members
    var staffMembers = await CreateActivity<IReadOnlyList<StaffMember>>(1, UpdateAbsenceContainer.Activities.GetStaffMembers)
        .Action((_, ct) => _logic.GetStaffMembersAsync(departmentId, ct))
        .ExecuteAsync(cancellationToken);

    // (18) For each staff member
    ...
}

First (17), we will create our first Action activity (17). The Action activity is where you do the actual work in the workflow. All other activities control the flow rather than actually do anything. Note that you can only do one thing in one action, i.e. you can only make one asynchronous call here. If you need to make two calls, then you need to make two action activities. An Action activity always calls one of your activity logic methods. In this case, it is the GetStaffMembers method. By calling them through an interface we can mock them when we write test cases for our workflow.

Let's look into what happens under the hood when we execute an action. In short, it uses AsyncManager to asynchronously execute a REST request. The workflow is postponed until the request has been completed. This is described in more detail in the implementation article .

1.2 For each staff member

The next activity is a ForEachParallelactivity (18).

private async Task DoUnderLock(IActivityLock activity, CancellationToken cancellationtoken)
{
    // (17) Get staff members
    ...

    // (18) For each staff member
    await CreateActivity(2, UpdateAbsenceContainer.Activities.ForEachStaffMember)
        .ForEachParallel(staffMembers, (staffMember, a, ct) => GetIndividualAbsenceAsync(a, staffMember, ct))
        .ExecuteAsync(cancellationtoken);
}

This activity loops over a list of items and it kicks off another activity for each of these items, in this case, GetIndividualAbsenceAsync. All these activities are done in parallel. This means that if that activity contains a REST request, it will send all those REST requests to Async Manager and then the workflow will be postponed. As soon as at least one of the requests has a response, the workflow will be activated again.

In the following subchapters, we will look into each of the activities in GetIndividualAbsenceAsync.

1.2.1 Ask staff member to report absence

The next activity is an Action activity. The Action activity here is that we want to send a manual request to a staff member to ask them to report their planned absence.

// (19) Get the deadline parameter
var deadline = GetWorkflowArgument<DateTimeOffset>(WorkflowParameters.Deadline);

// (20) Prepare a manual request
var manualRequest = _logic.InternalPrepareRequest(staffMember, deadline, cancellationToken);

// (21) Create the manual task and wait for the staff member to complete the manual request, or for the deadline
await CreateActivity(1, UpdateAbsenceContainer.Activities.RequestStaffMemberToReportAbscence)
    .Action((a, ct) => _logic.RequestStaffMemberToReportAbscenceAsync(a, manualRequest, ct))
    // (22) Set a deadline for this action
    .SetMaxTime(deadline)
    // (23) This part will catch the deadline
    .Catch(ActivityExceptionCategoryEnum.MaxTimeReachedError, (_, _) =>    { })
    .ExecuteAsync(cancellationToken);
  1. We need the workflow parameter deadline, so we start off by getting the value for that parameter (19).
  2. We need to prepare a data structure for the manual request. We do that synchronously (20).
  3. Finally, we create the Action activity (21). We set a maximum time to wait for the response (22). By catching the deadline (23) and simply doing nothing when we catch it, we will continue with the next activity.

1.2.2 Read absence requests

If either the staff member has reported their absence plans or if we reached the deadline, we will get to the next activity.

// (24) Read absence requests
var absenceRequests = await CreateActivity<IReadOnlyList<AbsenceRequest>>(2, UpdateAbsenceContainer.Activities.ReadAbsenceRequests)
    .Action((a, ct) => _logic.ReadAbsenceRequestsAsync(a, staffMember.Id, ct))
    .ExecuteAsync(cancellationToken);

This is an Action activity that will read all the absence requests that the staff member has entered (24).

1.2.3 For each absence request

Now that we have the absence requests (or none) for a staff member, we need to handle them one by one.

// (25) For each absence request
await CreateActivity(3, UpdateAbsenceContainer.Activities.ForEachRequest)
    .ForEachParallel(absenceRequests, (absenceRequest, a, ct) => HandleAbsenceRequest(a, absenceRequest, ct))
    .ExecuteAsync(cancellationToken);

This is done in a ForEachParallel activity (25).

1.2.3.1 Add to workforce calendar

The final activity is another Action activity.

// (26) Add to workforce calendar
await CreateActivity(1, UpdateAbsenceContainer.Activities.AddToWorkforceCalendar)
    .Action((a, ct) => _logic.AddToWorkforceCalendarAsync(a, absenceRequest, ct))
    .ExecuteAsync(cancellationToken);

We take the reported absence request and add it to a central calendar that is shared by the entire workforce (26).

Calling a workflow

So now we have a workflow implementation. How do we call the workflow to make a new workflow instance running?

From a contract perspective, the workflow is an implementation of a capability function or method. In our case, the contract looks like this:

public interface IWorkforceMgmtService
{
    /// <summary>
    /// Update the workforce calendar for department <paramref name="departmentId"/>. The update must be completed before <paramref name="deadline"/>.
    /// </summary>
    Task UpdateAbsenceAsync(string departmentId, DateTimeOffset deadline, CancellationToken cancellationToken = default);
}

When writing the API, we don't even have to know that this function is implemented as a workflow. This is how our controller looks like in the example:

[ApiController]
public class WorkforceManagementController : ControllerBase, IWorkforceMgmtService
{
    private readonly ILogger<WorkforceManagementController> _logger;
    private readonly IWorkforceMgmtCapability _capability;

    public WorkforceManagementController(ILogger<WorkforceManagementController> logger, IWorkforceMgmtCapability capability)
    {
        _logger = logger;
        _capability = capability;
    }

    /// <inheritdoc />
    [HttpPost("~/WorkforceManagment:UpdateAbscence")]
     public async Task UpdateAbsenceAsync(string departmentId, DateTimeOffset deadline, CancellationToken cancellationToken = default)
    {
        await _capability.CapabilityService.UpdateAbsenceAsync(departmentId, deadline, cancellationToken);
    }
}

It is in the service logic (where we always put the contract implementations) that we use the workflow implementation:

public class WorkforceMgmtService : IWorkforceMgmtService
{
    private readonly UpdateAbsenceContainer _updateAbsenceContainer;

   public WorkforceMgmtService(
        // (1) We to hand over the workflow capabilities to the workflow engine
        IWorkflowEngineRequiredCapabilities workflowCapabilities,
        // (2) This is the implementation of all actions in the workflow
        IUpdateAbsenceLogic logicV1)
    {
        // (3) Prepare a container for selecting the correct version of the workflow
        _updateAbsenceContainer = new UpdateAbsenceContainer(workflowCapabilities, logicV1);
    }

    /// <inheritdoc />
    public async Task UpdateAbsenceAsync(string departmentId, DateTimeOffset deadline, CancellationToken cancellationToken = default)
    {
        // (4) Get the correct implementation
        var implementation = await _updateAbsenceContainer
            .SelectImplementationAsync(1, 1, cancellationToken);

        // (5) Execute the workflow
        await implementation
            .SetParameter(nameof(departmentId), departmentId)
            .SetParameter(nameof(deadline), deadline)
            .ExecuteAsync(cancellationToken);
    }
}
  1. The constructor is responsible for setting up a workflow container.
    1. We need to get some Workflow Engine functionality (1).
    2. We need the logic for each Action activity (2).
    3. We use these parameters to create a new workflow container (3). It will be used to select the correct implementation if we have more than one version of the workflow, see (4) below.
  2. In the function implentation we need to do two things
    1. Get the correct implementation (4). For new workflow instances this means taking the latest version, and for existing workflow instances this means taking the same version as it had when it started.
    2. Execute the workflow (5). We set any workflow parameters, and then we execute the workflow.

How does it work?

Please see the article Implementation for a detailed description on how Workflow engine executes a workflow under the hood.