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);
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>();
}
}
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;
});
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;
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 ๐คทโโ๏ธ
}
}
}
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
}
}
}
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 theassembly
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;
}
}
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(...);
});
}
}
}
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();
}
}
}
Now, we can just include 1 assembly
registration attribute to register everything in our library:
[assembly: RegisterModule(typeof(LibraryRegistrationModule))]
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
- Wikipedia: Service Locator Pattern
- Wikipedia: Dependency Injection
- ASP.NET Core Docs: Options Pattern
- Mark Seemann Blog: Service Locator Anti-Pattern
- Xperience Docs: Custom Info Provider Example
- Xperience Docs: Custom Module Initialization
- Xperience Docs: Global Events
- Xperience Docs: Service Decoration
- Xperience Docs: Custom Email Provider Example
- Xperience Docs: Using Xperience APIs Externally
- GitHub Xperience Community: App Settings JSON Registration Library
- WiredViews: Web Design and Development Services
- Kentico Xperience Design Patterns: Good Startup.cs Hygiene
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.
Or my Kentico Xperience blog series, like:
Top comments (0)