In the previous posts, I explored how I can use Umbraco content to configure which service my application consumes and how I can compose multiple implementations with different strategies into a single service. Now, I want to take what I learned a step further and see how I can integrate context to further refine the construction of services.
Where were we?
This is what we did in the previous post:
- We added an extra blocklist with composition strategies
- We created a collection of factories, each able to produce a so-called "merger", corresponding to the options in the blocklist
- We updated the service provider to utilize the merger provider in order to convert a collection of services into a single implementation.
Experiment 3: Contextualizing service consumption
Say you have a related content model. On content pages, you want to display related content, but on the blog overview, you only want to show related blogs. In that case it's very relevant to contextualize your service consumption. It would allow you to use a different implementation on different page types, while allowing you to reuse existing infrastructure.
A short analysis of the process
Let's have another look at the service provider that we created, the CreateService
method in particular:
public IBanksService CreateService()
{
using var cref = umbracoContextFactory.EnsureUmbracoContext();
// π Step 1: Read the settings
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");
// π Step 2: Create prerequisits with the settings
var mergerSettings = settingsNode.BankCombinationStrategy?.First().Content;
var mergeStrategy = bankServiceMergerProvider.CreateMerger(mergerSettings);
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();
// π Step 3: Produce the result
var result = mergeStrategy.Merge(serviceCollection);
return result;
}
The process is composed of three clear steps:
- Reading the settings: We discover what we need to create the service
- Creating prerequisits with the settings: Using the settings, we create all the objects that we need to produce the result.
- Producing the result: When we have all the things we need, we can combine them together to create the result.
At this point, we've been exploring the possibilities in step 2 and step 3. Now we're going to have a look at what we can do with step 1.
Setting up
We're going to start with the same setup that we ended with in the previous post. Except, instead of banks, we'll display a list of users. We have the options to manually enter users or fetch them from Random Data Api. We also have the options to either combine all sources, only take the first or take the first but fall back on the next in case of errors.
I'm making a composition document type with the settings that we used on the settings node. This allows me to override settings on any page that implements this composition:
Hooking up the code
Now I'm going to update the service provider, including the contract. I'm adding an extra method so I can provide my own settings if I want to. We won't need it yet, but it will become very handy later.
public interface IUserServiceProvider
{
IUsersService CreateService();
IUsersService CreateService(IPublishedElement? mergerSettings, IEnumerable<IPublishedElement>? serviceImplementations);
}
public class UserServiceFactoryCollection(Func<IEnumerable<IUserServiceFactory>> items, IUmbracoContextFactory umbracoContextFactory, IUserServiceMergerProvider UserServiceMergerProvider)
: BuilderCollectionBase<IUserServiceFactory>(items), IUserServiceProvider
{
// π The original method now calls the new method with default parameters
public IUsersService CreateService()
=> CreateService(null, null);
public IUsersService CreateService(IPublishedElement? mergerSettings, IEnumerable<IPublishedElement>? serviceImplementations)
{
using var cref = umbracoContextFactory.EnsureUmbracoContext();
// π Instead of reading the settings directly from the settings node, we make a method that fetches the settings if none were provided
mergerSettings ??= FetchMergerSettings(cref.UmbracoContext);
var mergeStrategy = UserServiceMergerProvider.CreateMerger(mergerSettings);
serviceImplementations ??= FetchSourceSettings(cref.UmbracoContext);
var serviceCollection = serviceImplementations.Select(i => this.Select(f => f.Create(i)).FirstOrDefault(s => s is not null)).WhereNotNull();
// π the rest of this method remains the same as previously
var result = mergeStrategy.Merge(serviceCollection);
return result;
}
private IPublishedElement FetchMergerSettings(IUmbracoContext umbracoContext)
{
// π Starting from the current page, we check if we can find overrides for the settings. If not, we go up the ancestor tree until we find an override.
var selfAndAncestors = FetchSelfAndAncestorsControls(umbracoContext);
foreach(var page in selfAndAncestors)
{
if (page.OverrideUserCombination?.Count > 0) return page.OverrideUserCombination[0].Content;
}
// π If none of the ancestors provide an override, we fall back to the settings node, like before
var applicationSettings = FetchApplicationSettings(umbracoContext);
return applicationSettings.UserCombination!.First().Content;
}
private IEnumerable<IPublishedElement> FetchSourceSettings(IUmbracoContext umbracoContext)
{
var selfAndAncestors = FetchSelfAndAncestorsControls(umbracoContext);
foreach(var page in selfAndAncestors)
{
if (page.OverrideUserSources?.Count > 0) return page.OverrideUserSources.Select(us => us.Content);
}
var applicationSettings = FetchApplicationSettings(umbracoContext);
return applicationSettings.UserSources!.Select(uc => uc.Content);
}
private IEnumerable<IUserControls> FetchSelfAndAncestorsControls(IUmbracoContext umbracoContext)
{
// π Self and ancestors are only considered if they implement the composition.
var self = umbracoContext.PublishedRequest?.PublishedContent;
if (self is null) return Enumerable.Empty<IUserControls>();
return self.AncestorsOrSelf<IUserControls>();
}
private ApplicationSettings FetchApplicationSettings(IUmbracoContext umbracoContext)
{
// π In case we need to fall back to the default settings, we know exactly where to find them.
return umbracoContext.Content!.GetAtRoot().OfType<ApplicationSettings>().First();
}
}
If we give this a try on the article page for example, you can see that we can override the behaviour, simply by selecting a new option in Umbraco:
Contextualizing the service per content block
Say we don't just want to change the behaviour on the page as a whole, but we want to change the behaviour for a specific block on a page. Unfortunately, we don't have enough context to know which block we are rendering. We need to explicitly provide our own context.
At this point, it's no longer possible to simply consume the service directly. We need to consume the service provider and give it a new context. This is where that extra method on the contract comes in.
First, we'll create a new block for the blocklist:
Now we need to make a new view component:
// π Instead of the service, we consume the service provider
public class ManualUsersRowViewComponent(IUserServiceProvider UsersServiceProvider) : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync(BlockListItem<ManualUsersRow> block)
{
// π We read the settings from the block and normalize them
var sources = block.Content.OverrideUserSources?.Select(us => us.Content).ToList();
if (!(sources?.Count > 0)) sources = null;
// π Using the settings that we found, we create a new instance of the service
var UsersService = UsersServiceProvider.CreateService(
block.Content.OverrideUserCombination?.FirstOrDefault()?.Content,
sources);
// π The rest is the same as usual
var Users = await UsersService.GetUsersAsync();
return View("/Views/Partials/blocklist/Components/usersRow.cshtml", Users);
}
}
Now we can have multiple blocks of users on the same page, all with different behaviours:
What did I learn?
I learned that there are three distinct steps to dynamic service consumption: Reading settings, creating prerequisits and producing the result. I learned that customizing each step allows me to customize how a service is consumed in different ways.
I also learned that context allows me to add extremely fine control over the consumed services with relatively little effort. I can see that the page is not the only factor by which I can vary service consumption. I can also change services based on user accounts, personalization profiles and more.
Finally, I see that there is a limit to the context that is readily available to me, but that I can provide my own custom context to further refine control over service consumption.
Closing thoughts
I can see a lot of potential in this concept. I personally find that the context often poses a challenge when attempting to write clean code. What usually starts as an exception, can end up as a convoluted heap of if/else statements as new business rules are proposed. I find that the concept of service composition also makes a lot more sense when context is involved. On blog pages, you likely just want to show related blogs, while on other pages, you might want to display a variation of related content from different sources. Nevertheless, one might still argue whether or not it's appropriate to put the control of service composition in the hands of content editors.
What do you think of contextualizing service consumption? Is it a welcome solution or does it obfuscate the flow of logic? What would you use this concept for? Let me know in a comment!
That's all I wanted to share. Thank you for reading and I'll see you in my next blog! π
Top comments (0)