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

Setting up Persistence & File Uploads

Sipke Schoorstra
Geek Culture

--

In the previous part, we learned how to configure Elsa to provide workflows from JSON files.

In this part, we will return to the demo application at hand and implement the Document Upload screen. Pretty much nothing about this post is specific to Elsa, but instead it will result in a decent starting point to start invoking workflows in the next part.

Let’s get into it.

In addition to storing uploaded files for processing through workflows, we also want to store some metadata about these files. Hence, we will introduce a domain model called Document.

To store document entities, we need a database. We already use a SQLite database for Elsa, so it might make sense to use the same database. Which is what we will do.

Here’s the plan for this part:

  • Implement the File Upload screen.
  • Implement the Document domain model.
  • Implement DB persistence.
  • Implement local file storage using Storage.NET

Once we got this basic plumbing covered, in the next part we will be looking at the good stuff where we will be executing workflows that process uploaded files in the next part.

File Upload Screen

To implement the File Upload functionality, we will be reusing the home page (implemented in Index.cshtml and Index.cshtml.cs)

Index.cshtml

Let’s start by replacing the entire contents of Index.cshtml with the following:

Index.cshtml

This will display a simple form with just two fields and a submit button. Before this compiles, however, we will need to update the Index.cshtml.cs file next.

Index.cshtml.cs

Open Index.cshtml.cs and replace its contents in one fell swoop with the following:

Index.cshtml.cs

There are a few things of note here:

  • The class depends on two services not yet defined: IDocumentTypeStore and IDocumentService.
  • On GET, we select a list of available document types provided by IDocumentTypeStore, which as an abstraction for data access not yet implemented.
  • On POST, we open a stream to the uploaded file and invoke SaveDocumentAsync on the IDocumentServiceservice.
  • We then redirect to a page called "FileReceived" .

FileReceived.cshtml

This page displays a simple message to the user that the document was received successfully and displays the generated document ID.

Create this file in the Pages folder with the following content:

Create the “code behind” file to look like this:

Here we will simply receive the document ID, load it from the database, and return an HTTP 404 — Not Found response in case an unknown ID was provided. A future update might actually display the document details, such as its status and perhaps a download link to the uploaded file. But we don’t need any of that for the purposes of this tutorial.

Once we implement the missing models, services and data access, this page will allow the user to select a document type, a file, and submit the form.

Let’s tackle the missing models, services and data access next.

Domain Models

The domain models and services will mostly be provided and implemented by the DocumentManagement.Core project. I say “mostly”, because some abstractions, although provided by the core project, will be implemented in DocumentManagement.Persistence, as we will see.

Document

Create a new folder called Models and create a new class called Document as follows:

DocumentStatus

In the same folder, create an enum called DocumentStatus, as follows:

DocumentType

In the same folder still, create the following class:

Domain Services

The domain services will provide abstractions and some concrete implementations to deal with persisting documents and storing files. Some of these abstractions are implemented in the Core project, whilst others are implemented elsewhere, such as the implementation for IDocumentStore.

This architecture being setup here is my own loose interpretation of the onion architecture. The important parts are that we are separating infrastructural aspects such as data access and IO from the business domain layer.

IDocumentStore

Create a new folder called Services and add the following C# interface:

The document store service has just two responsibilities:

  • Save Document entities to the database.
  • Load Document entities from the database by ID.

IDocumentTypeStore

Create the IDocumentTypeStore interface with the following code:

The document type store also has two responsibilities, which are:

  • List all document types
  • Get a single document type by ID

We won’t be implementing document type management, so there’s no need for adding a write operation. Instead, we will setup EF Core to seed our database with a default set of document types.

ISystemClock & SystemClock

This interface abstracts access to DateTime.UtcNow, which is considered an external resource to the domain, which means we should abstract it away. This is helpful also when one wants to write some unit tests, allowing us to provide a mock implementation of the interface.

Create the ISystemClock.cs file and add the following content:

Create the concrete implementation in the same folder:

IFileStorage & FileStorage

The file storage service is responsible for storing files somewhere. The implementation will rely on Storage.NET abstractions so that our domain logic does not depend on concrete implementations directly. Instead, it will be up to our host application to configure file access, as it should be.

Create the following IFileStorage interface:

The file storage service takes care of writing a stream of data to a designated target (specified by the file name) and reads a stream of data from a given file name. It is up to the implementation to handle this.

Create the implementation as follows:

Notice that we rely on DocumentStorageOptions to provide us with a factory method for creating an IBlobStorage instance.

The IBlobStorage interface is provided by the Storage.Net package, so make sure to install it:

dotnet add DocumentManagement/src/DocumentManagement.Core/DocumentManagement.Core.csproj package Storage.Net

We also need to install the Microsoft.Extensions.Options package in order to use IOptions:

dotnet add DocumentManagement/src/DocumentManagement.Core/DocumentManagement.Core.csproj package Microsoft.Extensions.Options

DocumentStorageOptions

Create a new folder called Options and add the following class:

We will configure these options from Startup later on.

IDocumentService & DocumentService

The document service has just two responsibilities:

  • Store a given file stream on disk, create a document entity and store that one in the database.
  • Publish a domain event called NewDocumentReceived

Create the interface as follows:

And create the implementation with the following code:

The IMediator interface is provided by the MediatR package, so let’s install it:

dotnet add DocumentManagement/src/DocumentManagement.Core/DocumentManagement.Core.csproj package MediatR

NewDocumentReceived

The “new document received” event is published by the document service every time a new document is created.

This allows the application to perform certain tasks. For example, we might want to send someone an email about the new document for review.

As outlined in the introduction part of this series, we will be implementing this using workflows, but by employing this mediator pattern we could also simply implement a handler and do something interesting. The beautify of all this is extensibility without having to pollute the core domain library. Instead, we could implement separate class libraries, acting as “modules” plugging into the system.

Create a new folder called Events and add the following class:

IsExternalInit

Since we are using a C# 9 feature called “records” in a .NET Standard project, you will probably see a compiler error along the lines of:

The predefined type ‘System.Runtime.CompilerServices.IsExternalInit’ must be defined or imported in order to declare init-only setter.

To fix this, we must follow the provided advice and define IsExternalInit. To do this, create the following file in the root of the DocumentManagement.Core project folder:

Registering Domain Services

Before we move on to building out the persistence layer, let’s first create an extension method to facilitate the registration of our domain services with the service container.

Create a new folder called Extensions and add the following class:

We will be calling AddDomainServices from the Startup class in the web project a little later, after we completed the persistence layer.

Persistence

We will implement the persistence layer in the DocumentManagement.Persistence project.

More specifically, we will be using Entity Framework Core.

Here’s what we’ll be doing:

  • Define a DB context class.
  • Define a design-time DB context factory class (for generating migrations).
  • Generate migrations.
  • Implement IDocumentStore and IDocumentTypeStore
  • Implement a hosted service to auto-run migrations at application startup.
  • Provide an extension method to register EF Core & domain services with the service container.

Before we can use EF Core (and its SQLite provider) and hosted services, we need to install the following packages first:

  • Microsoft.EntityFrameworkCore.Design
  • Microsoft.EntityFrameworkCore.Sqlite
  • Microsoft.Extensions.Hosting.Abstractions
dotnet add DocumentManagement/src/DocumentManagement.Persistence/DocumentManagement.Persistence.csproj package Microsoft.EntityFrameworkCore.Designdotnet add DocumentManagement/src/DocumentManagement.Persistence/DocumentManagement.Persistence.csproj package Microsoft.EntityFrameworkCore.Sqlitedotnet add DocumentManagement/src/DocumentManagement.Persistence/DocumentManagement.Persistence.csproj package Microsoft.Extensions.Hosting.Abstractions

We will also be adding a project reference to DocumentManagement.Core, since we’ll be implementing a couple of interfaces:

dotnet add DocumentManagement/src/DocumentManagement.Persistence/DocumentManagement.Persistence.csproj reference DocumentManagement/src/DocumentManagement.Core/DocumentManagement.Core.csproj

Create a new DocumentDbContextclass in the root of the persistence project:

Notice that we are seeding the database with 3 document types:

  • “ChangeRequest”
  • “LeaveRequest”
  • “IdentityVerification”

We can potentially add additional document types and associate them with workflows without having to touch any of the application code to introduce new document types.

Document DB Context Design Factory

In order to let the dotnet ef tool generate migrations, we should implement a DB context factory class that the tool can use to instantiate new instances of the DB context.

Let’s go right ahead and create the following class:

Generate Migrations

Now that we have the DB context and factory in place, let’s generate the migrations by executing the following command (make sure to run the command from the same directory as where the project resides):

dotnet ef migrations add Initial

Make sure that the DocumentManagement.Persistence project targets net5.0:<TargetFramework>net5.0</TargetFramework>

Apply Migrations

With the migrations in place, we can now apply migrations manually using the dotnet ef tool and that would be fine. In fact, it would probably the smartest thing to do when working with production databases.

But during application development, especially when writing a tutorial, it’s way more convenient to auto-run migrations during application startup. Not to mention it’s way cooler too!

To that end, let’s create a hosted service that takes care of this for us.

Create a new folder called HostedServices and add the following class:

Not only are Hosted Services a great way to implement background jobs, they are perfect for executing application startup code to that uses async/await as is the case when applying migrations programmatically. When used in this way, the migrations will execute before the application is ready to start serving requests.

Implementing the Stores (aka Repositories)

Let’s implement the IDocumentStore and IDocumentTypeStore interfaces next.

Create a new folder called Services and add the following two classes:

Nothing too fancy going on here. These classes are simple wrappers around some EF Core operations. The interesting part might be that we’re injecting a DB context factory instead of an actual DB context directly. Although both options work, I prefer the factory strategy in order to keep the DB context as short-lived as possible, greatly simplifying asynchronous applications containing things background tasks (not seen here).

One improvement one might consider is making the SaveAsync method of the EFCoreDocumentStore class thread-safe by doing an upsert operation server-side rather than client side. Right now, if two threads were to try and save a document object with the same ID at the same time, chances are high that you end up with two records in the database.

To do a server-side upsert operation, you might consider using a 3rd party package such as EFCore.BulkExtensions, FlexLabs.Upsert or Entity Framework Extensions.

Registering Persistence Services

Let’s now create an extension method to facilitate the registration of our persistence services with the service container.

Create a new folder called Extensions and add the following class:

When invoked, that will take care of registering EF Core, our store implementations and of course our migrations runner.

Startup

With all the work we’ve done, all that’s left now is to invoke the two extension methods that register the domain & persistence services and to configure the file storage options introduced earlier.

Add the following private methods to the bottom of the Startup class:

private void AddDomainServices(IServiceCollection services)
{
services.AddDomainServices();

// Configure Storage for DocumentStorage.
services.Configure<DocumentStorageOptions>(options => options.BlobStorageFactory = () => StorageFactory.Blobs.DirectoryFiles(Path.Combine(Environment.ContentRootPath, "App_Data/Uploads")));
}
private void AddPersistenceServices(IServiceCollection services, string dbConnectionString)
{
services.AddDomainPersistence(dbConnectionString);
}

And then invoke these methods right after the line where we call AddworkflowServices:

// Domain services.
AddDomainServices(services);
// Persistence.
AddPersistenceServices(services, dbConnectionString);

Try It Out

At this point, we should be able to run the application, upload a file, and confirm that the file was saved and a document record was created, as shown in the following screen recording.

Next

This part didn’t necessarily introduce anything new related to Elsa, but we did setup a pretty decent base onto which we can now start hooking in custom logic to process uploaded documents. This was made easy by publishing the “new document received” domain event.

In the next part, we will make good use of this event by implementing a handler in the DocumentManagement.Workflows project that will in turn invoke the proper workflow (if any) associated with the document type.

--

--