Theme:

Composable Thinking for Extending Umbraco

Consider building your next Umbraco solutions with modularity and flexibility in mind.

Umbraco is known for being the friendly Composable Digital Experience Platform (DXP), it is much more than a Content Management System (CMS) these days with extensible components such as Forms, Commerce and Engage.

While these Add-Ons can be considered large composable pieces of the DXP puzzle, built-in to Umbraco are smaller extension points you may have used in extending Umbraco with custom user interfaces and capabilities beyond presenting Content and/or Media. Think the Sections, Trees, Dashboards, Notifications, Mappers and Url Providers.

Often when building Umbraco solutions, we fall into the trap of writing something bespoke to solve our problems and client needs.

What if there was a different way?

Consider building composable solutions.

What makes a solution composable?

By definition, a composable solution is one that is assembled with modular components which can be flexibly added in any combination. Each of these components would comply to specific shared interfaces allowing interoperability between them.

Ultimately having a set of reusable components that can be swapped in and out of your system to deliver similar or alternative functionality.

Typically this can be achieved by building your solution so that it doesn’t depend upon concrete implementations of services or classes.

A diagram of a composable solution showing the core solution, an abstractions package and first plus third party packages. Linking the main service to an interface which the services in the first and third party packages implement.

An Example Composable Solution

When should I make it composable?

When you find yourself implementing similar functionality across multiple projects, it's a strong indicator that your solution could benefit from being made composable.

This allows you to maintain a single, well-tested reusable implementation that can be deployed wherever needed.

When your solution addresses a common problem that isn't specific to a particular business domain or use case, making it composable allows it to be adapted and reused in various scenarios while maintaining a consistent core functionality.

What about composable components?

Composability doesn’t have to stop at your overall solution level, there is opportunity for the components you build to provide extension points for other packages to hook into.

Take the likes of these well known packages in the Umbraco ecosystem providing ways to customise your usage further:

  • Contentment - allowing you to provide your own IDataListSource allowing you to have custom items appear in your dropdown and pickers.
  • uSync - allowing you to add your own Migrators, Mappers, Serialisers and Handlers for items stored within your solution.

How do I build extension points in Umbraco?

There are multiple approaches to allowing for your solution to be extensible and composable.

Offer interfaces for Services to implement

Umbraco leans on the Microsoft.Extensions.DependencyInjection
 capabilties, utilising the IServiceProvider to handle dependency injection into classes.

Out of the box this has the ability to define multiple implementation types that satisfy a given interface.


// Composing
public class LocationsComposer : IComposer
{
	public void Compose(IUmbracoBuilder builder)
	{
		builder.Services
			.AddTransient<ILocationProvider, GoogleMapsLocationProvider>()
			.AddTransient<ILocationProvider, BingMapsLocationProvider>();
	}
}

// Usage
[Route("[controller]")]
public class LocationsController
{
	private readonly IEnumerable<ILocationProvider> _providers;

	public LocationsController(IEnumerable<ILocationProvider> providers)
	{
		_providers = providers;
	}

	[HttpGet]
	[Route("")]
	public Task<IActionResult> GetByNameAsync(string name)
	{
		var allLocations = new List<ILocation>();
		allLocations.AddRange(_providers.FindByNameAsync(name))
		return Ok(allLocations);
	}
}

Example of how to register multiple services and consume them

These can be then read in your controllers or services, allowing you to iterate through the collection of implementing services and filter if need be.

Within newer versions of .NET, you also have the ability to define services with a key, making it easier to get the correct implementing type.


// Compose
builder.Services
	.AddKeyedTransient<ILocationProvider, GoogleMapsLocationProvider>("GoogleMaps");

// Consume
var locationProvider = keyedServiceProvider.GetRequiredKeyedService<ILocationProvider>("GoogleMaps");

Example of defining keyed services and consuming them

Fire off Notifications

You can produce notifications to let consumers know when events are happening or have happened in your solution.

This leans towards producing solutions that are event-driven, pushing the action onto consumers to handle.

Umbraco has two types of notification patterns baked in:

  • Cancelable - Where you expect a consumer to interact with the message and provide feedback immediately. These interactive notifications are used to inform the publisher that they should cancel the current operation, e.g. your business validation rules have failed.
  • Fire and forget - Where you want to inform consumers that an event has occurred. These cannot cause the publisher to change its course of action. These are typically published when the scope is completed.


private async Task SaveAsync(ILocation location)
{
	using var scope = _scopeProvider.CreateCoreScope();
	var messages = _eventMessagesFactory.Get();

	// Fire an event to notify consumers we are saving a location
	var savingNotification = new LocationSavingNotification(location, messages);

	if (await scope.Notifications.PublishCancelableAsync(savingNotification))
	    return;

	_locationRepository.Save(location);

	// Fire an event to notify consumers a location has been saved
	var locationSavedNotification = new LocationSavedNotification(location, messages);

	scope.Notifications.Publish(locationSavedNotification);

	scope.Complete();
}

Example of publishing notificaitons

Gather implementations in Collections

Umbraco offers the ability to group implementations of a specific interface into collections. There are four main approaches to defining collections, offering unique benefits for each.

  • Set - an unordered collection.
  • Ordered - allows ordering of the elements in the collection, useful when you need things to appear / happen before or after something
  • Lazy - collection of elements that are lazily loaded, resolved and instantiated at their point of use
  • Weighted - order the elements based on a defined [Weight]

Choosing the most appropriate depends on your use case, but Umbraco uses Collections extensively for gathering implementations of:

  • ContentApps
  • Dashboards
  • DataEditors
  • Sections
  • Trees
  • ... and loads more


// Composing
public class AvailabilityProvidersComposer : IComposer
{
	public void Compose(IUmbracoBuilder builder)
	{
		builder.AvailabilityProviders()
			.Append<ResdiaryAvailabilityProvider>()
			.Append<OpenTableAvailabilityProvider>();
	}
}

// The collection
public class AvailabilityProviderCollection : BuilderCollectionBase<IAvailabilityProvider>
{
	public AvailabilityProviderCollection(Func<IEnumerable<IAvailabilityProvider>> items) : base(items)
	{
	}
}

// The builder for the collection
public class AvailabilityProviderCollectionBuilder : LazyCollectionBuilderBase<AvailabilityProviderCollectionBuilder, AvailabilityProviderCollection, IAvailabilityProvider>
{
	protected override AvailabilityProviderCollectionBuilder This => this;

	public IAvailabilityProvider? Get(string name) => this.FirstOrDefault(p => p.Name == name);
}

// Extension method
public static class UmbracoBuilderExtensions
{
	public static AvailabilityProviderCollectionBuilder AvailabilityProviders(this IUmbracoBuilder builder) =>
		builder.WithCollectionBuilder<AvailabilityProviderCollectionBuilder>();
}


// Consume
[Route("[controller]")]
public class AvailabilityController
{
	private readonly AvailabilityProviderCollection _providers;
	private readonly IOptions<AvailabilityProviderSettings> _options;

	public AvailabilityApiController(AvailabilityProviderCollection providers, IOptionsMonitor<AvailabilitySettings> options)
	{
		_providers = providers;
		_options = options;
	}

	[HttpGet]
	[Route("")]
	public Task<IActionResult> GetAsync(string location, DateTime startDateTime, DateTime endDateTime)
	{
		var providerName = _options.CurrentValue.ProviderName;
		var provider = _providers.Get(providerName);
		var availability = provider.GetAvailabilityAsync(location, startDateTime, endDateTime);
		return Ok(availability);
	}
}

Example defining a lazy collection

What about making my solution configurable?

Allowing end-users to configure your solution without writing many (if any) lines of code can lower the barrier to composing the perfect solution. There are different approaches to getting this configuration from simple to higher effort.

Options via App Settings

.NET allows configuration to be defined in JSON or Environment Variables which is then loaded in at runtime.


builder.Services.Configure<LocationSettings>(config.GetSection("Locations"));

Example of configuring Options via App Settings

Tip

You can store your secrets (connection strings and keys) locally when the .NET runtime environment is set to Development and have them load in on startup with .NET Secrets Manager.

Using a builder

Using the fluent builder pattern to provide a programmatic way of configuring your solution.


builder.AddLocationProviders(options => options.AddGoogleMaps(config["GoogleMapsApiKey"]));

Example of configuring with builder pattern

Any other considerations?

Making good decisions around your interface designs, make your methods asynchronous where you may be bound by I/O calls or heavy CPU usage, such as interacting with a database or making API calls to an external resource.

Provide a package containing abstractions for your solution and anything core to consumers such as notification classes to build handlers for.

Aiming for high quality documentation whether it is XML comments or full blown Markdown in your repository or Wiki entries providing good samples on how to use your package's composable features.