DEV Community

Dennis
Dennis

Posted on

Umbraco in control: Exploring user-managed service composition

Earlier, I wrote about controlling the consumption of services using Umbraco content. In that post, I explored how I can leverage Umbraco content to decide which implementation of a service I want to inject into my consumer code. In this post, I'm going to expand that concept and see if I can compose multiple services in a meaningful way. I'm going to use Umbraco content to decide how multiple services should interact with oneanother.

So where were we?

This is what we did in the previous post:

  • We set up a simple website with Umbraco 13 and the Clean starter kit.
  • We created a connection with random data api to fetch appliances
  • We displayed the appliances in a blocklist on a content page
  • We created an additional service implementation that uses Umbraco content as a source for appliances
  • We added a settings node with options to choose which service we want to fetch the data from
  • We leveraged the DI-container to directly inject the IAppliancesService into the view component instead of consuming the IApplianceServiceProvider.

Experiment 2: Composing multiple services

Sometimes you have to deal with multiple sources for similar data. Perhaps your application supports multiple payment providers for example and each provider supports different banks. You might have a primary source for specific data, but need an alternative source in case the primary source becomes unavailable.

Setting up

I'm going to start with the same setup that we ended with in the previous post. However, instead of appliances, I'll be using banks. I'll fetch banks from Random Data Api and I'll manually specify some in Umbraco.

I'm going to try out three different compositions:

  1. Just take the first
  2. Concatenate the results of each source
  3. Fall back on the next source if the previous source throws an error

Just like the services themselves, these aggregation methods are expressed in a blocklist on the settings node:

Screenshot of the aggregation methods expressed in Umbraco as a blocklist

Hooking up the code

All the code here is going to be very similar to the code in the first post. We have a user who chooses the implementation, so we're going to make some factories that produce implementations based on the chosen settings:

// NOTE: I used the term "merger", because it "merges" multiple implementations together, but also because Umbraco already uses the word "composer"
// ๐Ÿ‘‡ A "merger factory" creates an implementation of a merger based on the given settings
public interface IBankServiceMergerFactory
{
    IBankServiceMerger? Create(IPublishedElement settings);
}

// ๐Ÿ‘‡ A "merger" combines a set of services into a single service of the same type
public interface IBankServiceMerger
{
    IBanksService Merge(IEnumerable<IBanksService> implementations);
}
Enter fullscreen mode Exit fullscreen mode

A provider packs the various factories into a single accesspoint for consumer code, using Umbraco's collection pattern:

public interface IBankServiceMergerProvider
{
    IBankServiceMerger CreateMerger(IPublishedElement settings);
}

public class BankServiceMergerFactoryCollection(Func<IEnumerable<IBankServiceMergerFactory>> items)
        : BuilderCollectionBase<IBankServiceMergerFactory>(items)
        , IBankServiceMergerProvider
{
    public IBankServiceMerger CreateMerger(IPublishedElement settings)
    {
        foreach(var factory in this)
        {
            var merger = factory.Create(settings);
            if (merger is not null) return merger;
        }

        throw new InvalidOperationException("Unable to find an implementation for the merging strategy that is configured in Umbraco.");
    }
}
Enter fullscreen mode Exit fullscreen mode

The merger provider needs to be consumed by the service provider. We'll make a small change to the service provider:

// ๐Ÿ‘‡ Add the merger provider as constructor parameter to consume a merger
public class BankServiceFactoryCollection(Func<IEnumerable<IBankServiceFactory>> items, IUmbracoContextFactory umbracoContextFactory, IBankServiceMergerProvider bankServiceMergerProvider)
    : BuilderCollectionBase<IBankServiceFactory>(items), IBankServiceProvider
{
    public IBanksService CreateService()
    {
        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 bank service, because the settings node does not exist or is unpublished");

        // ๐Ÿ‘‡ Add a line to read the merge strategy from the Umbraco content
        var mergerSettings = settingsNode.BankCombinationStrategy?.First().Content;
        var mergeStrategy = bankServiceMergerProvider.CreateMerger(mergerSettings);

        // ๐Ÿ‘‡ Instead of just grabbing the first implementation, grab all implementations
        var implementations = settingsNode.BankSources!.Select(b => b.Content);
        var serviceCollection = implementations.Select(i => this.Select(f => f.Create(i)).FirstOrDefault(s => s is not null)).WhereNotNull();

        // ๐Ÿ‘‡ Use the merger to merge all implementations into a single one and return the result
        var result = mergeStrategy.Merge(serviceCollection);
        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

The first and easiest implementation of the factory is the "only take the first" strategy:

public class OnlyFirstMergerFactory : IBankServiceMergerFactory
{
    public IBankServiceMerger? Create(IPublishedElement settings)
    {
        if (settings is BanksFirstOnly) return new OnlyFirstMerger();
        return null;
    }

    private sealed class OnlyFirstMerger : IBankServiceMerger
    {
        public IBanksService Merge(IEnumerable<IBanksService> implementations)
        {
            return implementations.First();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that we haven't made a single change to the view component. The consumer code remains untouched!
Observing the behaviour on the frontend, we see that only the first configured service is consumed:

Screenshot of the behaviour of the "only first" strategy on the frontend

Next up is the "concatenate" strategy. This strategy fetches the data from all the services and merges them into a single model as follows:

public class ConcatenateMergerFactory
    : IBankServiceMergerFactory
{
    public IBankServiceMerger? Create(IPublishedElement settings)
    {
        if (settings is BanksConcatenate) return new ConcatenateMerger();
        return null;
    }

    private sealed class ConcatenateMerger : IBankServiceMerger
    {
        public IBanksService Merge(IEnumerable<IBanksService> implementations)
        {
            // ๐Ÿ‘‡ This strategy actually creates a whole new implementation of the service as a decorator on top of all the known implementations
            return new AggregatedBanksService(implementations.ToList());
        }

        private sealed class AggregatedBanksService(IEnumerable<IBanksService> implementations)
            : IBanksService
        {
            // ๐Ÿ‘‡ The new implementation takes the various collections and merges all results into a single model
            public async Task<BankCollection> GetBanksAsync()
            {
                List<BankCollection> results = [];

                foreach(var impl in implementations)
                {
                    results.Add(await impl.GetBanksAsync());
                }

                return new BankCollection(
                    string.Join(" & ", results.Select(r => r.Source)),
                    results.SelectMany(r => r.Items).ToList()
                );
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If we select this strategy in Umbraco and observe the behaviour in the frontend, we notice that not only all the banks from all the sources are combined into the list, but we also see that all sources are mentioned in the credits at the bottom:

A screenshot that demonstrates the behaviour of the "concatenate" strategy

Lastly, we implement the "fallback on error" strategy. This strategy is the same as "only take the first", but if the first source generates an error, this strategy tries the next source until it finds a source that doesn't give any errors or all options are exhausted:

public class ErrorFallbackMergerFactory(ILogger<ErrorFallbackMergerFactory.ErrorFallbackMerger.ErrorFallbackBanksService> logger)
    : IBankServiceMergerFactory
{
    public IBankServiceMerger? Create(IPublishedElement settings)
    {
        if (settings is BanksErrorFallback) return new ErrorFallbackMerger(logger);
        return null;
    }

    public sealed class ErrorFallbackMerger(ILogger logger) : IBankServiceMerger
    {
        public IBanksService Merge(IEnumerable<IBanksService> implementations)
        {
            return new ErrorFallbackBanksService(implementations.ToList(), logger);
        }

        public sealed class ErrorFallbackBanksService(IEnumerable<IBanksService> implementations, ILogger logger) : IBanksService
        {
            public async Task<BankCollection> GetBanksAsync()
            {
                List<Exception> errors = [];
                foreach(var impl in implementations)
                {
                    try
                    {
                        var result = await impl.GetBanksAsync();
                        return result;
                    }
                    catch(Exception e)
                    {
                        logger.LogError(e, "Failed to fetch banks from service {servicetype}, using fallback instead", impl.GetType());
                        errors.Add(e);
                    }
                }

                throw new AggregateException("Unable to fetch banks from any of the services", errors);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If we configure this option and observe the behaviour on the frontend, we don't see any difference compared to the "only take the first" strategy. However, if we make Random Data Api unavailable by disabling our internet connection, we see that the next service is used instead:

Screenshot of the observed behaviour of the "fallback" strategy when a service returns errors

On top of that, we can check out the Umbraco logs and see why the second service was used instead of the first:

Screenshot of the logs when a service gives an error and a fallback is used

What did I learn?

I learned that it's possible, not only to influence the service that is injected, but also to influence how multiple service can work together, only using Umbraco content. I learned that I can change this behaviour without touching consumer code and I learned that I can give content editors control over this behaviour.

I discovered that the components that I needed are quite simple, but the system as a whole is rather complex. I'm not sure if I would understand this code if I were new to a project that uses this technique.

Closing thoughts

It was a fun exercise to work out. It feels very cool that I can combine different data sources and services and I can influence how they work together.
However, I don't see any practical applications for this concept. I feel that when you work with multiple services, there is usually one specific way to logically combine them and there isn't really a need to give a content editor a choice. It's rather a gimmick that looks fun and impressive, but serves little practical purpose.

What are your thoughts on this concept? Do you have any practical applications where this would be useful? Let me know in a comment! This is all I wanted to share, thank you for reading and I'll see you in my next blog! ๐Ÿ˜Š

Top comments (0)