Part 4 of Building Workflow Driven .NET Applications with Elsa 2
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
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
Let’s start by replacing the entire contents of
Index.cshtml with the following:
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 and replace its contents in one fell swoop with the following:
There are a few things of note here:
- The class depends on two services not yet defined:
- 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
- We then redirect to a page called
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.
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.
Create a new folder called
Models and create a new class called
Document as follows:
In the same folder, create an enum called
DocumentStatus, as follows:
In the same folder still, create the following class:
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
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.
Create a new folder called Services and add the following C# interface:
The document store service has just two responsibilities:
Documententities to the database.
Documententities from the database by ID.
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.
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
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 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
dotnet add DocumentManagement/src/DocumentManagement.Core/DocumentManagement.Core.csproj package Microsoft.Extensions.Options
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
Create the interface as follows:
And create the implementation with the following code:
IMediator interface is provided by the MediatR package, so let’s install it:
dotnet add DocumentManagement/src/DocumentManagement.Core/DocumentManagement.Core.csproj package MediatR
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:
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.
We will implement the persistence layer in the
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 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:
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:
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:
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
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
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
SaveAsyncmethod of the
EFCoreDocumentStoreclass 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.
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.
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
private void AddDomainServices(IServiceCollection services)
// 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)
And then invoke these methods right after the line where we call
// Domain services.
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.
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.