Introduction
Kentico Xperience 13 is among the first CMS platforms to adopt .NET Core. You can imagine how excited our dev team has been. The reliance on a legacy platform is no longer an obstacle to adopting a full-featured CMS. Xperience is also a product in transition. In version 13, websites are built using Core, but the CMS application is still on ASP.NET Web Forms. Because of this, developing code that is shared between the applications can be awkward. We often create custom code that needs to be available in both the web site and in this CMS application. Microsoft has handled the basics with .NET Standard and Microsoft Extensions. However, if your components need to use out-of-the-box dependencies, using DI in the CMS application isn't straightforward. Although the CMS app uses Microsoft extensions under the covers, it wraps the DI within its own service locator, so that custom code doesn’t have direct access to the service collection.
Here's a solution that allows us to register the dependencies in our shared libraries using patterns that are familiar in .NET Core. Fortunately, there is a way to use the shared components in the CMS application, without adding legacy code to the shared libraries.
An example
To set the stage, let's consider a typical scenario. Imagine that we have an Xperience website that must be integrated with a learning management system (LMS). The website and the CMS app both need to use the custom components. The CMS app must synchronize course information into the content tree using a scheduled task. Additionally, both the website and email widgets must be able to query enrollment status for each user. So, again the CMS app and website must have access to some of the same components. In short, we need to be able to create custom service and repository classes in a shared library, which can be used in both the CMS app and the .NET Core website. However, we don’t want to add any code to support a legacy platform in this shared library. After all, we’re excited for the next major Xperience update, when ASP.NET Web Forms goes away.
Keeping the shared libraries clean
To implement this solution, we start with a clean shared library that doesn’t include any legacy-support code. Imagine that it starts with a service named LearningManagementService that requires several dependencies, ICourseRepository, IEnrollmentRepository, and Xperience’s built-in IUserInfoProvider. To make it easier for any project to register these interfaces, we provide an extension method for the service collection like this:
/// <summary> /// Register dependencies required for the LearningManagementService. /// This can be used by both the .NET Core web app and the CMS app. /// </summary> /// <param name="services"></param> /// <returns></returns> public static IServiceCollection AddLearningManagementServices(this IServiceCollection services) { services.AddSingleton<ICourseRepository, CourseRepository>(); services.AddSingleton<IEnrollmentRepository, EnrollmentRepository>(); services.AddTransient<ILearningManagementService, LearningManagementService>(); return services; }
Then when setting up the .NET Core application, we call the extension method to register dependencies required by the library. Here’s an example of adding the call to this extension method in typical .NET Core startup code for Xperience:
services.AddAuthentication(); services.Configure<RouteOptions>(options => options.LowercaseUrls = true) .AddAuthorization() .AddKatalyst() .AddLearningManagementServices();
Notice that when registering these dependencies, we are using the service collection provided by the .NET Core runtime and that Xperience’s built-in dependencies are added to the same collection. This means that we can inject custom repositories as well as built-in dependencies, like IUserInfoProvider into a service implementation.
public LearningManagementService(ICourseRepository courseRepository, IEnrollmentRepository enrollmentRepository, IUserInfoProvider userInfoProvider) { _courseRepository = courseRepository; _enrollmentRepository = enrollmentRepository; _userInfoProvider = userInfoProvider; }
Dependency injection in the CMS app
With Xperience 13, I was hoping we’d be able to simply add a .NET Standard library to the CMS app, add Microsoft Extensions, and use the same extension methods to register dependencies. Not so.
Although, Xperience is using Microsoft Extensions behind the scenes. The CMS app still uses a legacy service locator to wrap it. On the plus side, keeping this service locator provides backward compatibility for code upgraded to Xperience 13. It’s a good thing. However, this is the primary reason reusing the shared library leaves us with a few awkward options for registering dependencies:
- We could register each component by decorating them with Xperience’s RegisterImplementation attribute. However, this would clutter up our shared library with legacy-support code.
- We could register the components with Xperience’s service locator (i.e., Service.Use<T, T>), but this would require maintaining separate registration code for the CMS app.
What I wanted was access to the service collection so that I could simply add dependencies using shared extension methods.
Solution
To solve this challenge, I considered creating a separate DI container in the CMS app. There’s an example using Autofac in the Xperience documentation. However, this would also require registering Xperience’s built-in services with the container. It would also require providing a service locator so that dependencies could be used by the many Xperience component types that require default constructors. Since the next major update of Xperience will undoubtedly make a separate DI container unnecessary, this didn’t seem practical.
Instead, I created a helper method, RegisterWithKenticoServiceLocator, to read a service collection and call Service.Use for each dependency. Conceptually, this allows merging a service collection into Xperience’s protected container using its built-in service locator.
/// <summary> /// With the provided collection of service descriptors, use CMS.Core.Service.Use to register /// each service with Kentico Xperience's service locator. This allows a collection of /// service descriptors to be registered with the same DI container used in Xperience's /// CMS application. /// </summary> /// <param name="services"></param> public static void RegisterWithKenticoServiceLocator(this IServiceCollection services) { foreach (var serviceDescriptor in services) { var transient = (serviceDescriptor.Lifetime == ServiceLifetime.Transient); if (serviceDescriptor.Lifetime == ServiceLifetime.Scoped) { throw new NotSupportedException( @"A scoped service cannot be registered using Xperience's service locator, CMS.Core.Service.Use."); } if (serviceDescriptor.ImplementationInstance != null) { Service.Use(serviceDescriptor.ServiceType, serviceDescriptor.ImplementationInstance, null); continue; } if (serviceDescriptor.ImplementationType != null) { Service.Use(serviceDescriptor.ServiceType, serviceDescriptor.ImplementationType, null, transient); continue; } if (serviceDescriptor.ImplementationFactory != null) { throw new NotSupportedException( @"An implementation factory cannot be registered using Xperience's service locator, unless it is wrapped with a local function that passes null as the IServiceProvider parameter."); } } }
With this helper method added to a CMS project, both the CMS app and the .NET Core app can use the service collection methods. In a CMS app, the best place to call this code is in the OnPreInit method of a code-only module. The following example module creates an empty service collection, calls two extension methods to register dependencies, and then calls RegisterWithKenticoServiceLocator to merge the collection into the CMS app’s DI container.
using CMS; using CMS.Base; using CMS.DataEngine; using Sample.Management.Extensions; using Sample.Management.Modules; using Sample.Shared.Configuration; using Microsoft.Extensions.DependencyInjection; [assembly: RegisterModule(typeof(ServiceConfigurationModule))] namespace Sample.Management.Modules { public class ServiceConfigurationModule : Module { public ServiceConfigurationModule() : base(nameof(Sample) + "." + nameof(ServiceConfigurationModule)) { } /// <summary> /// Use this code-only module to add additional dependencies to the /// DI container used by Xperience's built-in service locator. /// This will allow injecting built-in components and custom components /// as needed. /// </summary> protected override void OnPreInit() { base.OnPreInit(); if (SystemContext.IsCMSRunningAsMainApplication) { // Create an empty service collection, so that we can call the same // extension methods used when registering the shared dependencies in // .NET Core web application setup. IServiceCollection additionalServices = new ServiceCollection(); // Call all extension methods required to setup dependencies. additionalServices.AddLearningManagementServices() .AddAzureSearchServices() // Call RegisterWithKenticoServiceLocator to add these // dependencies to Xperience's built-in container. .RegisterWithKenticoServiceLocator(); } } } }
Using Services in the CMS app
In the CMS app, custom code is typically invoked from a scheduled task, module, or macro method. These components don’t support constructor-based dependency injection, because Xperience creates these components using their default constructors. However, we can still create the top-level service in the dependency chain using Xperience’s service locator and trust that the rest of the required dependencies will be injected as needed. For example, in this sample macro method Service.Resolve<ILearningManagementService>() is used to create the service, and ICourseRepository, IEnrollmentRepository, and IUserInfoProvider are automatically injected into the service’s constructor. Even though we must use the service locator pattern at the top, its use is limited and doesn’t get out of control.
using CMS; using CMS.Core; using CMS.MacroEngine; using CMS.Membership; using Sample.Management.MacroMethods; using Sample.Shared.Interfaces; [assembly: RegisterExtension(typeof(SampleMacroMethods), typeof(UserInfo))] namespace Sample.Management.MacroMethods { public class SampleMacroMethods : MacroMethodContainer { [MacroMethod(typeof(string), "Return the number of courses the current user is enrolled in.", 1)] [MacroMethodParam(0, "user", typeof(UserInfo), "UserInfo object.")] public static string GetEnrolledCourseCount(EvaluationContext context, params object[] parameters) { if ((parameters == null) || (parameters.Length == 0) || !(parameters[0] is UserInfo)) { return null; } var userInfo = (UserInfo)parameters[0]; var learningManagementService = Service.Resolve<ILearningManagementService>(); return learningManagementService.GetCoursesByUserEnrollment(userInfo.UserID) .Count .ToString(); } } }
Closing
As a result, we can keep our shared libraries clean from legacy specific code. They should be ready for Xperience’s complete upgrade to .NET Core. At the same time, preparing for the future doesn’t prevent us from reusing content in email widgets or running scheduled tasks on the backend CMS server. If’ you’d like to try this out, check out my boilerplate sample on GitHub.
Have questions on how to get the most out of your Kentico implementation? Drop me a line on Twitter (@HeyMikeWills), or view my other articles here.