Theme:

Modular Umbraco sites with NuGet, RCLs and uSync

Many Umbraco teams have some kind of “starter” template for their projects - a collection of common content types or functionality, and helpers for things like SEO metadata. The goal is to give development a speed boost, and ultimately make projects more profitable.

This presents a couple of challenges:

  • A one-size-fits-all solution rarely works, and lazy time-pressured developers will likely leave any unused code in place
  • When the time comes to upgrade you must update that code everywhere it’s used, and keeping track of all of places can be a challenge
  • Similarly if a bug is found, that needs to be patched everywhere too

In short it’s a maintainability nightmare! What you gain in efficiency at the start of a project can quickly become a burden down the line.

The same can be said for “starter kits”, site-builder solutions, and .NET templates, where they offer a full-fledged site in moments + access to the source code to customise as you need. But customising code breaks future upgradability, effectively forking the project at that point in time.

A Modern Approach

At Bump we found ourselves building the same modules and bits of functionality again and again. We needed an efficient way to reuse things across projects, but also allow us to mix-and-match pre-built features from a central place. We decided to create a system of reusable features and plugins.

Each feature needed the ability to ship its own default views, models, schema – but with flexibility to override and customise at every point.

Features could be versioned and therefore built and upgraded in isolation from client projects. To provide maximum reusability the implementations could be kept lean and aim to support as many Umbraco versions as possible (i.e. v9-v13).

We just needed to work out how…

Enter Razor Class Libraries

Turns out .NET has a pretty neat way of doing exactly this in the form of Razor Class Libraries!

RCLs allow views, static files, and other code to be shipped in a versioned class library & NuGet package.

To setup an RCL we create a new .NET Class Library project and update the csproj file to use the Microsoft.NET.Sdk.Razor SDK:


<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
</Project>

Project: SDK Configuration

We add the views and any other code needed for our feature into the RCL project. Because we don’t have access to a consuming project’s Models Builder setup from within our feature package, we stick to using old school .Value<T>() or introduce our own view model / interfaces that can be tweaked and extended in the implementing project.

If we want to customise a view within a specific project, all that's required is to create a new view at the same name / path. We try to make this easier by following a predictable convention across all features, such as Views/Partials/Elements/{ContentTypeAlias}.cshtml.

Within our RCLs we can multi-target code against different framework versions to bridge across any difference in Umbraco features (Umbraco 9 = .NET 5; Umbraco 10 = .NET 6; Umbraco 11 & 12 = .NET 7; Umbraco 13 = .NET 8). To enable this, in the csproj file we can set the TargetFrameworks property to include a list of targeted .NET versions. We can also choose to set the LangVersion property to “latest”, meaning .NET will try and allow the latest C# language features to be used regardless of the underlying framework version.


<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFrameworks>net6.0;net7.0;net8.0</TargetFrameworks>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
</Project>

Project: Target Frameworks and Language Version configuration

In a scenario where a piece of code needs to differ between Umbraco versions, such as where a core namespace has been renamed, C#’s #if directives allow different code to run depending on the .NET version in use:


#if NET8_0_OR_GREATER
using Umbraco.Cms.Core.DependencyInjection;
#else
using Umbraco.Cms.Web.Common.DependencyInjection;
#endif

.NET Version Configuration

uSync Roots

Like many in the Umbraco world, we rely on uSync to safely serialise our CMS schema changes to disk and easily deploy them between environments.

uSync v13.1 introduced the concept of Roots – a way of having several schema definition files for the same entities that get combined into one definition at import time.

In addition to the default uSync/v9 folder a new uSync/Root folder can be created and contain “root” definitions. This folder structure mirrors that of the default folder. At import time these definitions are merged together, including content type properties and even data type configurations (like Block Lists).

To allow importing and editing of Roots this must be enabled in the uSync appsettings


"uSync": {
  "Settings": {
    "LockRoot": false
  }
}

uSync Lock Root Configuration

We can define the content types and data types a given feature needs and have those files copied into the consuming project without affecting the existing uSync folder in that project – files can be dropped into the uSync/Root folder instead. Because we use the Block List quite heavily for building page content, a given feature would likely include some Element Types + dictionary values + the Block List data type itself (containing only this single Block). The site likely already contains some other Blocks that we don’t want to lose. uSync Roots takes care of importing and merging all of this schema for us!

To handle copying the feature’s files into uSync/Roots, we add an MSBuild target to our library that runs before the consuming project builds.


<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Target Name="CopyUsyncRoot" BeforeTargets="Build">
    <!-- Specify path to uSync Root files -->
    <ItemGroup>
      <UsyncFiles Include="$(MSBuildThisFileDirectory)..\content\uSync\Root\**\*.*" />
    </ItemGroup>

    <!-- Copy to project directory (for packaging) -->
    <Copy SourceFiles="@(UsyncFiles)" DestinationFiles="@(UsyncFiles->'$(MSBuildProjectDirectory)\uSync\Root\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />

    <!-- Copy to output directory (for running locally) -->
    <Copy SourceFiles="@(UsyncFiles)" DestinationFiles="@(UsyncFiles->'$(OutDir)\uSync\Root\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
  </Target>
</Project>

MSBuild Configuration

Whilst this approach works well in many cases it does also have some limitations. If you have multiple feature packages containing uSync definitions for the same things, and thus files named the same, they will overwrite each other – the last files to be copied wins.

After a great chat with Kevin at Codegarden this year, since uSync v13.3 it is now possible to register Roots programmatically. This means each of our features can register its own Root folder by implementing ISyncFolder; removing the need for the MSBuild script, and giving greater control over the order the uSync definitions are resolved in:


public class SyncFolder : ISyncFolder
{
    public string Path => "uSync/MyProject.Accordion/";

    public int Weight => int.MinValue;
}

uSync Custom Roots Configuration

The real power of uSync Roots comes when we need to roll out additional fields, or add / change / remove a field within a given project. Any updates to schema within a new version of the feature package are imported at the next uSync Import. The “root” uSync definitions remain part of the package, but any custom changes for a project are reflected within the local uSync/v9 folder just like you’d expect.

"Installing" Features

A complete feature probably looks something like this – an RCL project, containing a uSync folder and (only) the definition files that feature needs, plus a Razor view at a convention-defined name and location. This project can be compiled and bundled into a NuGet package for consumption

Example of Accordion setup

We deliver our features via a private NuGet feed for the specific client, through GitHub Packages or Azure DevOps or MyGet, that can then be installed into a target Umbraco project.

The developer experience for “installing” a feature is no different to if you were installing any other open source plugin via NuGet – install the package to the project, boot the site, uSync automatically imports its schema, and you’re away!

It is necessary to tell .NET where to locate our private packages, which can be done by adding a NuGet.config to our project:


<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <packageSources>
        <add key="github" value="https://nuget.pkg.github.com/{ORG-NAME}/index.json" />
    </packageSources>
</configuration>

NuGet Configuration Example

We even created a handy interface in GitHub Actions where a user can click a few boxes and create a new project with the latest versions of the selected feature packages installed! This makes this approach to building sites even more accessible to the various players in a project team.

GitHub Actions Prompt with Project Configuration options

Results

Building this unique approach to a composable “baseline” for our clients who have multiple Umbraco sites has become integral to how we build sites with some of our clients.

As V13->V17 upgrades come around this is no longer feeling like a daunting task. Once an individual feature is upgraded it is simply a case of updating the corresponding package via NuGet – the updated code comes across and Umbraco updates its database like normal.

Indeed this approach does require some up-front thinking, but the rewards can be huge… This year we’ve seen development time for a typical website drop from weeks to only a matter of days!