Kentico Xperience Design Patterns: Registering Xperience Dependencies

seangwright

Sean G. Wright

Posted on June 27, 2022

Kentico Xperience Design Patterns: Registering Xperience Dependencies

Kentico Xperience has supported dependency resolution (both in ASP.NET Core and .NET 4.x) for a few versions, through the service locator pattern ๐Ÿง.

Now, with Kentico Xperience 13, it fully supports dependency injection (DI) out of the box using ASP.NET Core. We can always inject any of Xperience's built-in services into our custom types without needing to worry about how those services are constructed ๐Ÿคฉ.

However, we still need to be conscious of Xperience's underlying architecture when doing anything beyond simple use-cases ๐Ÿ˜ฎ.

If we don't pull back the curtain, there are things we might do to improve convenience that have significant impacts on code reusability, discoverability, and automated testing ๐Ÿ˜Ÿ.

๐Ÿ“š What Will We Learn?

  • How does Xperience manage internal dependencies?
  • How can we customize Xperience's internal types?
  • When do customizations become inflexible?
  • Best practices for registering custom types with Xperience.

๐Ÿ’Ž Xperience's DI Container

In Kentico Xperience's core, there is a static service locator class CMS.Core.Service. With it, we can request any service that Xperience has registered in its internal DI container.

IUserInfoProvider provider = Service.Resolve<IUserInfoProvider>();

UserInfo user = provider.Get(1);
Enter fullscreen mode Exit fullscreen mode

Note: This DI container is actually backed by Microsoft.Extensions.DependencyInjection.ServiceCollection, which is the same type that backs ASP.NET Core's DI container ๐Ÿค“ and it works in both .NET 4.8 and .NET 6.0.

However, it is a completely separate container from the one ASP.NET Core provides us ๐Ÿค” so we should follow Xperience's documentation for dependency registration for Xperience type customization.

We can also use this service locator to register types with Xperience's DI container. The service registration needs to be performed in a custom Xperience Module during the PreInit phase of application startup:

public class CustomUserInfoProvider : UserInfoProvider
{
    public override ObjectQuery<UserInfo> Get()
    {
        // do something special ๐Ÿคทโ€โ™‚๏ธ
    }
}

// ...

public class CustomModule : Module
{
    public CustomModule : base(nameof(CustomModule)) { }

    protected override OnPreInit()
    {
        Service.Use<IUserInfoProvider, CustomUserInfoProvider>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Since it's using the .NET ServiceCollection underneath, we can even use it to register configuration via the options pattern (again in the Module.OnPreInit method):

// I'm making up a `CustomUserOptions` type here as an example
public class CustomUserOptions
{
    public int DefaultUserID { get; set; }
}

// ...

Service.Configure<CustomUserOptions>(o =>
{
    o.DefaultUserID = 1;
});
Enter fullscreen mode Exit fullscreen mode

This means we should also be able to retrieve this configuration through Service.Resolve anywhere in our application:

var options = Service.Resolve<IOptions<CustomUserOptions>>();

int userID = options.Value.DefaultUserID;
Enter fullscreen mode Exit fullscreen mode

This isn't very useful outside of an ASP.NET Core application or modern (.netcoreapp3.1 +) .NET class libraries. We should use the built-in .NET DI container for this kind of thing, so it's more just an interesting observation.

We could use Service to get access to any Xperience type we want in our ASP.NET Core app, but in user-land code (code that isn't part of a framework), using a service locator for retrieving services is an anti-pattern ๐Ÿ˜ฉ.

Instead, use constructor dependency injection wherever possible ๐Ÿ™‚, and hide away your use of the Service class if you must use it. This will make your code more testable and predictable.

Customizing Xperience's Internal Types

Above, we looked at an example of how to customize Xperience's built-in types (like IUserInfoProvider) using the Service.Use method.

However, there's a much more common pattern using assembly attributes for registering dependencies that you'll see in blog posts and Xperience's documentation:

// This tells Xperience to add our custom type to its services container
[assembly: RegisterCustomProvider(typeof(CustomUserInfoProvider))]

namespace Sandbox.Data
{
    public class CustomUserInfoProvider : UserInfoProvider
    {
        public override ObjectQuery<UserInfo> Get()
        {
            // do something special ๐Ÿคทโ€โ™‚๏ธ
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This assembly attribute registration pattern is also used for custom modules, for example when creating a custom module to handle global events:

[assembly: RegisterModule(typeof(CustomGlobalEventsModule))]

namespace Sandbox.Data
{
    public class CustomGlobalEventsModule : Module
    {
        public CustomGlobalEventsModule
            : base(nameof(CustomGlobalEventsModule)) { }

        protected override void OnInit()
        {
            base.OnInit();

            DocumentEvents.Insert.After += Document_Insert_After;
        }

        private void Document_Insert_After(
            object sender, DocumentEventArgs e)
        {
            // Add custom actions here
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

These assembly attributes are a lot simpler and seem to be the way to go:

  • Create our custom class
  • Add an attribute above
  • Our customization is registered and ready to run ๐Ÿ˜!

Note: We'll typically only use Service.Use when we want to have an explicit ordering to service decoration, because the assembly attributes for dependency registration are non-deterministic.

Non Flexible Dependency Registration

Xperience's documentation provides plenty of examples of registering customizations in the same assembly, and often the same file, where they are defined.

This is perfect for helping developers understand concepts ๐Ÿ‘๐Ÿพ. However, as our applications grow in size and complexity, and we discover use-cases for using Xperience's APIs outside of traditional web applications, we will find we've strung ourselves up in our dependencies ๐Ÿ˜’.

In ASP.NET Core, we are used to adding NuGet packages containing types that customize our applications. We enable those customizations when we call extension methods that add types to the IServiceCollection or modify request processing of the IApplicationBuilder.

Simply adding a NuGet package or reference to a .NET project (typically) does not change the behavior of our application - this is going to be a .NET developer's expectation ๐Ÿ˜ค.

The problem with assembly registration attributes like [assembly: RegisterModule()] and [assembly: RegisterCustomProvider()] is they are DI container configuration calls that always ๐Ÿ˜ฒ execute in a running application, even if they aren't in our main application's code.

External Applications

Imagine we create a .netstandard2.0 class library (so that we can share its code with both our CMS .NET 4.8 application and live site ASP.NET Core application) and this library contains our custom Page Type definitions and some other code we want to share between both applications.

It's also likely that this library registers several custom providers and modules using assembly attributes.

Now, if we decide we want to use this library in an external application like a console application, we cannot opt-out of those registered modules and custom providers ๐Ÿ˜”. They always come along for the ride and automatically augment our system.

At WiredViews we often use .NET 6 console applications to iterate quickly on ETL/data import or migration processes and often we want to selectively opt-in to specific custom modules or types and opt-out of others.

When the referenced code contains these assembly attributes, our hands are tied - we can't turn them off.

Testing and Troubleshooting

Other times, a developer might want to test a specific scenario locally with a custom provider that behaves slightly differently or use an updated custom module implementation.

If class libraries are distributed through NuGet packages and those libraries have assembly registration attributes, what are the answers to the following questions ๐Ÿค”?

  • How would a developer easily set up their testing scenario?
  • How can they control what dependencies are registered and executed?
  • How do new developers even know what customizations are running in an application? Do they have to go digging through all the source code to find out ๐Ÿ˜ฉ?

In the modern ASP.NET Core world, we're used to commenting out an extension method in our middleware pipeline or services registration to turn functionality off.

We could use app settings to replicate this behavior even when our NuGet packages contain [assembly: RegisterModule()] calls. Those modules or custom types would need to check the app settings to enable/disable the custom behavior ๐Ÿ˜ฏ.

But, this requires developers to have one set of expectations for non-Xperience application customizations and a completely different set for Xperience customizations ๐Ÿ˜‘.

There's a better way!

Flexible Dependency Registration

All we need to do to bring some consistency and flexibility to our applications is to remove our dependency registration from our class libraries and move it into our consuming applications.

This way customizations only turn 'on' when they are explicitly registered in the applications that want them ๐Ÿค—.

In the ASP.NET Core world, if we are practicing good Startup.cs hygeine we might end up with a XperienceRegistrations.cs class:

[assembly: RegisterModule(CustomGlobalEventsModule)]
[assembly: RegisterCustomProvider(CustomUserInfoProvider)]

namespace Sandbox.Web.Configuration;

public static class XperienceRegistrations
{
    public static IServiceCollection AddAppXperience(
        this IServiceCollection)
    {
        // register our Xperience services with ASP.NET Core
        services.AddKentico();

        // ...

        return services;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice, we opt-in to our Xperience customizations that require assembly attributes by registering them in the same locationn that all our other Xperience-specific customizations are defined.

This makes it extremely clear what is being customized in an app and allows developers to turn things on/off in a way that is very similar to all the other code they work with in ASP.NET Core ๐Ÿ’ช๐Ÿฝ.

We can follow this pattern in our CMS ASP.NET 4.8 application by creating a DependencyRegistrationModule in that application's project:

[assembly: RegisterModule(typeof(DependencyRegistrationModule))]

[assembly: RegisterModule(typeof(CustomGlobalEventsModule))]
[assembly: RegisterCustomProvider(typeof(CustomUserInfoProvider))]

[assembly: RegisterModule(typeof(CMSCustomGlobalEventsModule))]

namespace CMSApp.Configuration
{
    public class DependencyRegistrationModule : Module
    {
        public DependencyRegistrationModule 
            : base(nameof(DependencyRegistrationModule)) { }

        protected override OnPreInit()
        {
            // register any services we want to access through
            // Service.Resolve in custom modules or elsewhere
            Service.Use(() =>
            {
                return new CreditCardService(...);
            });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we intentionally registered CMSCustomGlobalEventsModule in the CMS but not in our ASP.NET Core application. Had we defined these assembly registration attributes in a shared class library, we wouldn't be able to do this ๐Ÿ˜.

Managing Registration Complexity

We might look at this and think we traded one form of complexity for another. Sure, we now have full control over which services and modules are registered in each application, but we also have to remember to register them in all applications that need them ๐Ÿคจ. Before, all we needed to do was reference the class library and ... bam, it was done for us ๐Ÿคท๐Ÿปโ€โ™€๏ธ.

This is true, however we can recreate another pattern that ASP.NET Core libraries often use to make things easier for ourselves - gathering all dependencies up into a single place to be registered.

Since the assembly attributes are really just calls into Xperience's internals to 'register' things, we can reproduce this with a single Custom Module that makes these calls explicitly:

namespace Sandbox.Library
{
    public class LibraryRegistrationModule : Module
    {
        public LibraryRegistrationModule
            : base(nameof(LibraryRegistrationModule) { }

        protected override OnPreInit()
        {
            Service.Use<IUserInfoProvider, CustomUserInfoProvider>();
        }

        protected override OnInit()
        {
            new CustomGlobalEventsModule().OnInit();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we can just include 1 assembly registration attribute to register everything in our library:

[assembly: RegisterModule(typeof(LibraryRegistrationModule))]
Enter fullscreen mode Exit fullscreen mode

This is like an ASP.NET Core library's UseX() extension method on IServiceCollection. If we are ok with the defaults, we call that extension and the library handles everything for us, but we still get to explicitly opt-in.

Likewise, if we are ok with our class library's Xperience customization defaults, adding a single assembly registration attribute brings in everything ๐Ÿ‘๐Ÿฟ.

They key here is that adding a reference to an assembly (either via a .NET project or NuGet reference) doesn't change the behavior of our system. We have to be clear that we want a customization and add a line of code ๐Ÿ˜Ž. This means we can just as easily opt-out without having to throw away the entire class library's code.

๐Ÿ Conclusion

Kentico Xperience 13 has been able to support both the older ASP.NET Web Form CMS application and modern ASP.NET Core applications with shared libraries and APIs. This is an impressive feat of engineering.

Until we are in a fully ASP.NET Core world with Kentico Xperience (hopefully, just a few weeks away from the June 2022 publication of this blog post), we will need to juggle platform dependencies and customizations in a couple different ways.

If you are interested in the future of Kentico Xperience from a technical perspective, checkout this video below from the Kentico Xperience Connection: 2022 Conference.

The Xperience assembly attributes for dependency registration let us tap into the platform for full customization.

However, if we aren't careful, referencing a class library or NuGet package forces consumers to use our customizations because with these assembly attributes the dependencies are automatically 'on'. This makes our code less reusable and our applications less flexible.

Thinking about dependencies in a modern way and following the patterns of ASP.NET Core gives us some better approaches that make our applications more understandable and debuggable, and let's us opt-in to the Xperience customizations we want.

We should be explicit about which types and modules our applications use by registering them next our our applications other dependency injection management.

As always, thanks for reading ๐Ÿ™!

References


We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!

Also check out the Kentico Xperience Developer Hub.

If you are looking for additional Kentico content, checkout the Kentico or Xperience tags here on DEV.

#kentico

#xperience

Or my Kentico Xperience blog series, like:

๐Ÿ’– ๐Ÿ’ช ๐Ÿ™… ๐Ÿšฉ
seangwright
Sean G. Wright

Posted on June 27, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

ยฉ TheLazy.dev

About