Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save sfmskywalker/b4402199d4be1f4cf7cebd56ba91df4c to your computer and use it in GitHub Desktop.

Select an option

Save sfmskywalker/b4402199d4be1f4cf7cebd56ba91df4c to your computer and use it in GitHub Desktop.
Groundwork Elsa Medium import HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Groundwork: Modular Persistence Without Relational Lock-In</title>
<meta name="description" content="Groundwork came from a concrete Elsa maintenance problem: avoiding per-module EF Core migrations across providers, without giving up document databases such as MongoDB." />
<link rel="canonical" href="https://www.elsaworkflows.io/blog/groundwork-and-the-persistence-boundary-in-elsa" />
<meta property="og:type" content="article" />
<meta property="og:title" content="Groundwork: Modular Persistence Without Relational Lock-In" />
<meta property="og:description" content="Groundwork came from a concrete Elsa maintenance problem: avoiding per-module EF Core migrations across providers, without giving up document databases such as MongoDB." />
<meta property="og:url" content="https://www.elsaworkflows.io/blog/groundwork-and-the-persistence-boundary-in-elsa" />
<meta property="og:image" content="https://elsa-workflows.github.io/elsa-blog/assets/2026-06-12-groundwork-and-the-persistence-boundary-in-elsa/featured.png" />
<meta property="article:published_time" content="2026-06-12" />
<meta property="article:modified_time" content="2026-06-13" />
<meta property="article:author" content="Sipke Schoorstra" />
<meta property="article:tag" content="groundwork" />
<meta property="article:tag" content="dotnet" />
<meta property="article:tag" content="persistence" />
<meta property="article:tag" content="databases" />
<meta property="article:tag" content="elsa-workflows" />
</head>
<body>
<article>
<h1>Groundwork: Modular Persistence Without Relational Lock-In</h1>
<p><em>By Sipke Schoorstra · 2026-06-12</em></p>
<p><img src="https://elsa-workflows.github.io/elsa-blog/assets/2026-06-12-groundwork-and-the-persistence-boundary-in-elsa/featured.png" alt="Groundwork: Modular Persistence Without Relational Lock-In" /></p>
<p><strong>Groundwork came from a concrete Elsa maintenance problem: avoiding per-module EF Core migrations across providers, without giving up document databases such as MongoDB.</strong></p>
<h1>Groundwork: Modular Persistence Without Relational Lock-In</h1>
<p>I started Groundwork because Elsa&#39;s persistence story was becoming too expensive to maintain.</p>
<p>Elsa is modular. Modules need durable state. In a typical .NET application, the obvious answer is EF Core, and for many applications that is exactly the right answer. You define entities, configure mappings, generate migrations, and move on.</p>
<p>That model gets less pleasant when you are building a framework.</p>
<p>If every Elsa module that needs persistence owns EF Core migrations, every storage change starts multiplying. A module needs a new table or index. That change needs to work for SQLite, SQL Server, PostgreSQL, and any other relational provider we support. The next module has its own migrations. The next provider has its own differences. Over time, a lot of the work is not really domain work anymore. It is migration maintenance.</p>
<p>And that is only the first half of the problem.</p>
<p>The second half is that I did not want Elsa&#39;s modular persistence story to become relational-only.</p>
<p>Something like YesSQL is very close to what I wanted in one sense. It gives you document-like persistence on top of relational databases and avoids the &quot;every module brings its own EF model and migration set&quot; problem. That is a useful model.</p>
<p>But Elsa should not force the host application into a relational database just because a module needs durable state. If an application is using MongoDB, that should not become a second-class deployment option.</p>
<p>So the thing I wanted was roughly this:</p>
<p>Module-friendly persistence, without per-module EF Core migrations, and without assuming every provider is relational.</p>
<p>That is where Groundwork came from.</p>
<h2>The missing layer</h2>
<p>Groundwork is not an ORM in the usual sense. It is a small persistence foundation that lets a module describe its storage needs before a provider decides how to materialize them.</p>
<p>The key object is a <code>StorageManifest</code>.</p>
<p>A manifest says: this module owns these storage units, this is their schema version, these capabilities are required, these fields are indexed, these queries are expected, and this is how physicalized the provider is allowed to make the storage.</p>
<p>The support-ticket example in the Groundwork README is intentionally ordinary. It has tickets with a string identity, JSON content, optimistic concurrency, a unique ticket-number index, and queryable fields such as customer, status, assignee, and priority.</p>
<p>Trimmed down slightly, the manifest starts like this:</p>
<pre><code class="language-csharp">const string DocumentKind = &quot;supportTicket&quot;;
const string SchemaVersion = &quot;1.0.0&quot;;
var manifest = new StorageManifest(
new StorageManifestIdentity(&quot;support-tickets&quot;),
new StorageManifestOwner(&quot;sample.support&quot;),
new StorageManifestVersion(SchemaVersion),
[
new StorageUnit(
new StorageUnitIdentity(DocumentKind),
&quot;Support ticket&quot;,
new WorkloadClassification(
WorkloadFamily.RuntimeDefinedBusinessData,
WorkloadCandidateCategory.GroundworkDefault),
LifecyclePolicy.Mutable,
IdentityPolicy.StringId(),
TenancyPolicy.None,
ConcurrencyPolicy.Optimistic(),
SerializationPolicy.Json(),
[
Keyword(&quot;by-ticket-number&quot;, &quot;ticketNumber&quot;, isUnique: true),
Keyword(&quot;by-status&quot;, &quot;status&quot;)
],
[
Query(&quot;find-by-ticket-number&quot;, &quot;by-ticket-number&quot;),
Query(&quot;list-by-status&quot;, &quot;by-status&quot;, QuerySortSupport.Both, QueryPagingSupport.Offset)
],
PhysicalizationPolicy.Portable)
],
new HashSet&lt;string&gt; { &quot;schema-history&quot;, &quot;optimistic-concurrency&quot; },
[]);
</code></pre>
<p>The helper used by the README for indexes is explicit about what is portable:</p>
<pre><code class="language-csharp">static IndexDeclaration Keyword(string identity, string field, bool isUnique = false) =&gt;
new(
identity,
[new IndexField(field)],
IndexValueKind.Keyword,
isUnique,
true,
MissingValueBehavior.Excluded,
new HashSet&lt;PortableQueryOperation&gt; { PortableQueryOperation.Equal });
</code></pre>
<p>The module is not creating an EF Core migration here. It is not creating a MongoDB collection either. It is declaring intent.</p>
<p>That is the level of abstraction I wanted for Elsa modules. Not &quot;here is a SQL table&quot;, and not &quot;here is a Mongo collection&quot;, but &quot;here is the durable shape this module needs&quot;.</p>
<h2>Indexes are part of the contract</h2>
<p>The part that makes this work is that Groundwork is deliberately modest about queries.</p>
<p>For portable document storage, a module declares indexes and queries up front:</p>
<pre><code class="language-csharp">Keyword(&quot;by-ticket-number&quot;, &quot;ticketNumber&quot;, isUnique: true);
Keyword(&quot;by-customer&quot;, &quot;customerId&quot;);
Keyword(&quot;by-status&quot;, &quot;status&quot;);
Query(&quot;find-by-ticket-number&quot;, &quot;by-ticket-number&quot;);
Query(&quot;list-by-status&quot;, &quot;by-status&quot;, QuerySortSupport.Both, QueryPagingSupport.Offset);
</code></pre>
<p>Each index is a contract with the provider. In the README, <code>Query</code> mirrors the same equality-only operation:</p>
<pre><code class="language-csharp">static PortableQueryDeclaration Query(
string identity,
string indexName,
QuerySortSupport sort = QuerySortSupport.None,
QueryPagingSupport paging = QueryPagingSupport.None) =&gt;
new(
identity,
indexName,
new HashSet&lt;PortableQueryOperation&gt; { PortableQueryOperation.Equal },
sort,
paging);
</code></pre>
<p>That narrowness is intentional.</p>
<p>If a provider-neutral layer accepts arbitrary queries, it usually ends up lying. Either one provider supports a query well, another provider scans too much data, or the abstraction grows provider-specific escape hatches until the shared contract stops meaning anything.</p>
<p>Groundwork starts smaller. Equality queries over declared indexes are portable. Undeclared indexes fail clearly. Provider-specific optimization can still happen, but it happens behind the provider boundary.</p>
<p>For Elsa, that is important. A module should not accidentally work on PostgreSQL and then become unusable on MongoDB because it smuggled in a relational assumption.</p>
<h2>Providers materialize the same intent differently</h2>
<p>Once a manifest exists, the provider owns the mechanics.</p>
<p>Startup creates the manifest and then chooses a provider. For SQLite, the README shows this:</p>
<pre><code class="language-csharp">var connection = new SqliteConnection(&quot;Data Source=support-tickets.db&quot;);
var provider = new ProviderIdentity(&quot;groundwork-sqlite&quot;, &quot;1.0.0&quot;);
await new SqliteGroundworkMaterializer(connection)
.MaterializeAsync(manifest, provider);
IDocumentStore store = new SqliteDocumentStore(connection, manifest);
</code></pre>
<p>The same sample can swap the provider:</p>
<pre><code class="language-csharp">var client = new MongoClient(&quot;mongodb://localhost:27017&quot;);
var database = client.GetDatabase(&quot;support&quot;);
var provider = new ProviderIdentity(&quot;groundwork-mongodb&quot;, &quot;1.0.0&quot;);
await new MongoDbGroundworkMaterializer(database)
.MaterializeAsync(manifest, provider);
IDocumentStore store = new MongoDbDocumentStore(database, manifest);
</code></pre>
<p>That is the distinction Groundwork is trying to preserve.</p>
<p>The module declares one manifest. SQLite, SQL Server, PostgreSQL, and MongoDB do not have to use the same physical structure. Relational providers can use document envelopes, declared index rows, schema history, and projection tables where appropriate. MongoDB can use native collections and native indexes.</p>
<p>Same intent. Different mechanics.</p>
<p>This is why the YesSQL comparison is useful but not quite enough. YesSQL-style persistence solves a real modularity problem, but it assumes the relational world. Groundwork keeps the module-facing model similar in spirit while letting a document database be a real provider, not an afterthought.</p>
<h2>The application code stays boring</h2>
<p>The document-store API is intentionally small.</p>
<p>Saving a support ticket can be as simple as serializing a CLR object or anonymous object into the JSON envelope:</p>
<pre><code class="language-csharp">var ticket = new
{
ticketNumber = &quot;TCK-1001&quot;,
customerId = &quot;acme&quot;,
status = &quot;open&quot;,
priority = &quot;high&quot;
};
var created = await store.SaveAsync(new SaveDocumentRequest(
DocumentKind,
ticket.ticketNumber,
SchemaVersion,
JsonSerializer.Serialize(ticket)));
if (created.Status != DocumentStoreWriteStatus.Saved)
throw new InvalidOperationException($&quot;Ticket was not saved: {created.Status}&quot;);
</code></pre>
<p>Querying uses a declared index:</p>
<pre><code class="language-csharp">var openTickets = await store.QueryAsync(
new DocumentStoreQuery(DocumentKind, &quot;by-status&quot;, &quot;open&quot;, skip: 0, take: 25));
</code></pre>
<p>Updating can use optimistic concurrency:</p>
<pre><code class="language-csharp">var assignedTicketJson = JsonSerializer.Serialize(new
{
ticketNumber = &quot;TCK-1001&quot;,
customerId = &quot;acme&quot;,
status = &quot;assigned&quot;,
priority = &quot;high&quot;
});
var updated = await store.SaveAsync(new SaveDocumentRequest(
DocumentKind,
&quot;TCK-1001&quot;,
SchemaVersion,
assignedTicketJson,
ExpectedVersion: created.Document!.Version));
if (updated.Status == DocumentStoreWriteStatus.ConcurrencyConflict)
throw new InvalidOperationException(&quot;Ticket changed before the assignment was saved.&quot;);
</code></pre>
<p>There is nothing especially glamorous here, which is part of the point.</p>
<p>Module code should be able to save, load, update, and query its own durable documents without taking a dependency on EF Core migrations or MongoDB-specific setup. The provider package can still do serious provider work. It just does not leak that work into every module.</p>
<h2>What this could mean for Elsa</h2>
<p>For Elsa, the aim is mostly about reducing persistence maintenance while keeping provider choice real.</p>
<p>It gives modules a way to declare storage without bringing migration files for every relational provider. It gives provider packages a common contract to materialize. It gives document databases a place in the architecture instead of treating them as a special case after the relational model has already won.</p>
<p>That does not mean every Elsa store should be forced through the same generic document-store shape.</p>
<p>This is an important distinction. Groundwork can be the provider-neutral foundation underneath Elsa persistence while still exposing more specialized contracts for runtime workloads.</p>
<p>Bookmarks, workflow instances, scheduler state, execution mailboxes, outbox records, logs, locks, and leases all have different requirements. Some need resume-oriented indexes. Some need append and retention behavior. Some need claiming, ordering, retry, expiry, or compare-and-set semantics.</p>
<p>Those workloads should not sit outside Groundwork just because they are more specialized. They probably need Groundwork-backed contracts that describe their real behavior instead of flattening everything into ordinary document storage.</p>
<p>That boundary matters.</p>
<p>The useful thing is not that Groundwork hides all database differences. It does not, and it should not pretend to.</p>
<p>The useful thing is that an Elsa module can describe durable intent once, and the selected provider can decide how to make that intent real.</p>
<p>That is the layer I wanted.</p>
</article>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment