DEV Community

Dennis
Dennis

Posted on

Umbraco in control: exploring user-managed execution paths

This week I've been thinking about some solutions that I've developed recently and the challenges that I have come across. I've had some ideas that might help me solve some of these challenges, so I wanted to do some experiments and take y'all with me while I do so.

I would like to try out various ways in which I can influence the services that my code consumes using Umbraco content. For example:

  • All pages in a website have related content, but on blog pages, I only want to see related blogs.
  • A page should show data from an external service, but should gracefully fall back to a different source if the external service is unreachable
  • This shop is connected to service X, but we want to transition to service Y.

I'm going to set up a simple website and try out a variety of techniques and see what is nice and what isn't.

⚠️ This is not a step-by-step tutorial
This article focusses on specific coding concepts and I left out pieces of code that I deemed irrelevant

Getting started

For this experiment, I'm going to use a standard Umbraco 13 installation and install the clean starter kit, so I have some basic content to start with.

The package script writer website is very helpful to quickly set it all up! This is the setup that I'm going to use.

Now since I use source code models in my daily work, I will update the project to use source code models as well:

appsettings.json

"ModelsBuilder": {
  "ModelsMode": "SourceCodeManual",
  "ModelsNamespace": "UmbracoExperiments.CodeFlows.Web.Common.Models.Pages",
  "ModelsDirectory": "~/Common/Models/Pages/Generated"
}
Enter fullscreen mode Exit fullscreen mode

I quickly patch up the namespaces in my views and I'm ready to go.

Experiment 1: Switchable services

You might recognize this: A client wants you to build an integration with some external service, but this service either doesn't have a sandbox environment, or the sandbox environment is so lacking that you can't properly test your integration.

Creating the required content

Let's start by setting up the actual integration. I'll be using the random data api to obtain a list of appliances, because why not.

A simple element type will allow me to insert a list of appliances into a content page:

Screenshot of the element type that I created to display appliances

I will need several objects in code:

A contract for my service:
IAppliancesService.cs

public interface IAppliancesService
{
    Task<ApplianceCollection> GetAppliancesAsync(int amount);
}

public record class Appliance(string Id, string Name, string? Brand);

public record class ApplianceCollection(string Source, IReadOnlyCollection<Appliance> Items);
Enter fullscreen mode Exit fullscreen mode

A client for the random data api:
RandomDataAppliancesClient.cs

public class RandomDataAppliancesClient(HttpClient Client, IJsonSerializer JsonSerializer)
{
    public async Task<IReadOnlyCollection<RandomDataAppliance>?> GetAppliancesAsync(int size)
    {
        string url = $"https://random-data-api.com/api/v2/appliances?size={size}";
        var response = await Client.GetAsync(url);

        return JsonSerializer.Deserialize<IReadOnlyCollection<RandomDataAppliance>>(await response.Content.ReadAsStringAsync());
    }
}

public class RandomDataAppliance(int id, Guid uid, string brand, string equipment)
{
    public Appliance AsBusinessModel()
        => new (uid.ToString(), equipment, brand);
}
Enter fullscreen mode Exit fullscreen mode

An implementation of the contract that uses the random data client:
RandomDataAppliancesService.cs

public class RandomDataAppliancesService(RandomDataAppliancesClient randomDataClient)
    : IAppliancesService
{
    public async Task<ApplianceCollection> GetAppliancesAsync(int amount)
    {
        var randomDataAppliances = await randomDataClient.GetAppliancesAsync(amount)
            ?? throw new InvalidOperationException("Cannot process the appliances, because the external service returned null");

        return new ApplianceCollection("Random Data Api", randomDataAppliances.Select(r => r.AsBusinessModel()).ToList());
    }
}
Enter fullscreen mode Exit fullscreen mode

And finally a composer so that Umbraco can register the services in DI:
Experiment1Composer.cs

public class Experiment1Composer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddHttpClient<RandomDataAppliancesClient>();

        builder.Services.AddSingleton<IAppliancesService, RandomDataAppliancesService>();
    }
}
Enter fullscreen mode Exit fullscreen mode

With the infrastructure in place, it's time to display the data on a page. I need to create a razor view for the blocklist:

appliancesRow.cshtml

@inherits UmbracoViewPage<ApplianceCollection>
@using Umbraco.Cms.Core.Models.Blocks
@using UmbracoExperiments.CodeFlows.Web.Experiment1

<div class="row clearfix">
    <div class="col-md-12 column">
        @foreach(var appliance in Model.Items)
        {
            <div class="post-preview">
                <h2 class="post-title">@appliance.Name</h2>
                <p class="post-meta">@appliance.Brand</p>
            </div>
        }
        <p class="caption">Brought to you by: @Model.Source</p>
        <hr />
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

I also need a view component so I can take the content from the block and pass it to the service to obtain my business models:

AppliancesRowViewComponent.cs

public class AppliancesRowViewComponent(IAppliancesService appliancesService) : ViewComponent
{
    public async Task<IViewComponentResult> InvokeAsync(BlockListItem<AppliancesRow> block)
    {
        var content = block.Content;

        var appliances = await appliancesService.GetAppliancesAsync(content.Amount);
        return View("/Views/Partials/blocklist/Components/appliancesRow.cshtml", appliances);
    }
}
Enter fullscreen mode Exit fullscreen mode

And now I have a simple connection with an external service:

A screenshot of what the external data looks like when displayed on a content page

Expanding the concept

Now say the data from our sandbox environment wasn't as excellent as the data from Random Data Api and we want to use a different data source for local testing purposes. Let's say we write some custom data in Umbraco to display while working locally.

I'm going to use a blocklist to define some custom data in Umbraco. A settings node in the root of the content tree is a good place to store this custom data:

Screenshot of some example content that I want to use

Once again, I create an implementation of the service contract and I register the implementation in the DI-container:

public class UmbracoAppliancesService(IUmbracoContextFactory umbracoContextFactory)
    : IAppliancesService
{
    public Task<ApplianceCollection> GetAppliancesAsync(int amount)
    {
        using var cref = umbracoContextFactory.EnsureUmbracoContext();

        var settingsNode = cref.UmbracoContext.Content?.GetAtRoot().OfType<ApplicationSettings>().FirstOrDefault();
        if (settingsNode is null) throw new InvalidOperationException("Cannot obtain appliances from Umbraco content, because the settings node does not exist or is unpublished");

        var appliances = settingsNode.Appliances?.Select(b => b.Content).OfType<ApplicationAppliance>().Take(amount)
            ?? Enumerable.Empty<ApplicationAppliance>();

        return Task.FromResult(new ApplianceCollection("Local content", appliances.Select(a => new Appliance(a.Key.ToString(), a.ApplianceName!, a.ApplianceBrand)).ToList()));
    }
}

public class Experiment1Composer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddHttpClient<RandomDataAppliancesClient>();

        builder.Services.AddSingleton<IAppliancesService, UmbracoAppliancesService>();
        //builder.Services.AddSingleton<IAppliancesService, RandomDataAppliancesService>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now I can pick whichever implementation I want, simply by uncommenting one of the options.

I want to take it another step further though. I would like to swap out implementations at runtime, by selecting a specific implementation in Umbraco. My idea is to introduce a new blocklist in which I can choose the source of the data:

A screenshot of different implementations as a choice in Umbraco

I need to make a change in how I obtain a reference to my service. Instead of injecting implementations directly, I need to use factories:

public interface IApplianceServiceFactory
{
    IAppliancesService? Create(IPublishedElement settings);
}

public class RandomDataAppliancesServiceFactory(RandomDataAppliancesService service)
    : IApplianceServiceFactory
{
    public IAppliancesService? Create(IPublishedElement settings)
    {
        // 👇 The random data api works without any configuration, so we can return the service directly
        if (settings is ApplianceFromRandomDataApi) return service;
        return null;
    }
}

public class UmbracoAppliancesServiceFactory : IApplianceServiceFactory
{
    public IAppliancesService? Create(IPublishedElement settings)
    {
        // 👇 The manual strategy requires some configuration from the blocklist item, so the configuration is passed to the service before returning it to the consumer.
        if (settings is ApplianceManuallySpecified manualSettings) return new UmbracoAppliancesService(manualSettings);
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

The UmbracoAppliancesService of the service needs a little update:

// 👇 Instead of umbraco context factory, we insert the settings object directly
public class UmbracoAppliancesService(ApplianceManuallySpecified settings)
    : IAppliancesService
{
    public Task<ApplianceCollection> GetAppliancesAsync(int amount)
    {

        var settingsNode = this.settings;

        var appliances = // ... the rest remains the same
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally I need a provider so I can neatly pack the factories into a single access point for consumption. I use Umbraco's collection builder pattern to do that:

public interface IApplianceServiceProvider
{
    IAppliancesService CreateService();
}

public class ApplianceServiceFactoryCollection(Func<IEnumerable<IApplianceServiceFactory>> items, IUmbracoContextFactory umbracoContextFactory)
    : BuilderCollectionBase<IApplianceServiceFactory>(items), IApplianceServiceProvider
{
    public IAppliancesService CreateService()
    {
        // 👇 The settings can be found in the root of the content tree
        using var cref = umbracoContextFactory.EnsureUmbracoContext();

        var settingsNode = cref.UmbracoContext.Content?.GetAtRoot().OfType<ApplicationSettings>().FirstOrDefault();
        if (settingsNode is null) throw new InvalidOperationException("Cannot find implementation for appliance service, because the settings node does not exist or is unpublished");

        // 👇 From the settings, we'll take the first configured service
        var implementation = settingsNode.ApplianceSource?.First().Content;

        // 👇 Using the settings content, we can ask the factories for an implementation of the appliances service
        foreach (var factory in this)
        {
            var service = factory.Create(implementation);
            if (service is not null) return service;
        }

        throw new InvalidOperationException("No implementation exists for the configured appliance source");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now I can register all the different implementations of the appliances service in the composer:

public class Experiment1Composer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddHttpClient<RandomDataAppliancesClient>();
        builder.Services.AddSingleton<RandomDataAppliancesService>();

        builder.WithCollectionBuilder<ApplianceServiceFactoryCollectionBuilder>()
            .Append<RandomDataAppliancesServiceFactory>()
            .Append<UmbracoAppliancesServiceFactory>()
            ;

        builder.Services.AddSingleton<IApplianceServiceProvider>(sp => sp.GetRequiredService<ApplianceServiceFactoryCollection>());
    }
}
Enter fullscreen mode Exit fullscreen mode

The cherry on the cake

So far, I haven't done anything new. Usually I would update my consumer code to consume the IApplianceServiceProvider and be done, but I want to try and see if I can leave the consumer code completely untouched.

In order to do that, I make a small change to the composer by adding this line:

builder.Services.AddScoped(sp => sp.GetRequiredService<IApplianceServiceProvider>().CreateService());
Enter fullscreen mode Exit fullscreen mode

It's very important in this case that the service is added as scoped. The service should be created newly on each request so that changes in the umbraco content reflect immediately. However, it's okay to use the same instance of the service in a single request.

Now without updating any consumer code, I'm able to change implementations at runtime. The viewcomponent still thinks it directly consumes the IAppliancesService!

A short screencapture that shows how the consumed service changes when you change the order of the settings in the Umbraco content section

If you don't like it that the provider logic is hidden from the consumer, you can also skip the last line in the composer and let your consumers depend on IApplianceServiceProvider instead.

What did I learn from this?

I learned that it's possible to make the creation of services completely separate from consuming code by leveraging the dependency injection container. I learned that I can use Umbraco to configure which service I want my code to consume. I see several benefits and drawbacks:

Pros and cons of explicitly consuming the provider

  • ✅ It's easier to follow where your service comes from if you're unfamiliar with the code
  • ✅ You have no business logic in the DI container
  • ❌ It's slightly more difficult to unit test your consuming code as you need to mock the provider on top of the service
  • ❌ You need to call the creation method in each consumer, leading to slightly repeated code and perhaps longer methods

Pros and cons of hiding the provider

  • ✅ Easier to unit test
  • ✅ Easier to follow the relevant parts of the code flow
  • ❌ As a beginner, it's difficult to understand where the service comes from and why you receive a specific implementation
  • ❌ The creation of the service is business logic and it's debatable whether that logic belongs inside the dependency injection container or not

Pros and cons of involving Umbraco

  • ✅ I have fine control over the services that my code consumes at any point
  • ❌ There is a significant amount of additional code required

Closing thoughts

I see a lot of potential in this idea. Being able to replace existing services without updating consuming code is really nice if your service has many references. This only works though if you have a clear separation between your business models and your DTOs.

I also see a lot of potential in the provider logic. I used Umbraco, but this could obviously be anything. Feature flags, appsettings and more.

I'm going to be doing more experiments with this. I would like to explore how I could dynamically combine the random data api and the umbraco content in various ways and I want to explore how I can dynamically change the selected services based on the page that you visit.

That's all I want to share in this post. Please let me know what you think with a comment, thank you for reading and I'll see you in my next blog! 😊

Top comments (0)