Part 4 of Building Workflow Driven .NET Applications with Elsa 2
Setting up Persistence & File Uploads
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:
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:
There are a few things of note here:
- The class depends on two services not yet defined:
IDocumentTypeStore
andIDocumentService
. - 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 theIDocumentService
service. - 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
andIDocumentTypeStore
- 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 DocumentDbContext
class 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 theEFCoreDocumentStore
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.