Skip to content
Xperience by Kentico StyleguideXperience by Kentico StyleguideXperience by Kentico Styleguide
GitHubYouTube

Platform Customization

Custom Modules

Store modifable configuration in a Custom Module

Suggested

  • Create a Custom Module Object Type
  • Model the Object Type to store all the required Administration modifiable custom settings
  • Modify these settings per environment, at runtime, as neeeded

Why?

Some configuration needs to be editable at runtime. Typically, configuration found in appsettings.json files is not accessible to site administrators and could require an application restart for the application to read updated values.

Settings modeled in a Custom Module Data Type are accessible by the application as soon as they are updated, making them useful for values that need to be changed as need arises without require a deployment or access to the hosting environment.

One Custom Module Per Object Type

Recommended

Why?

Since Global Events can be handled in any Custom Module class, the logic for handling these events can accidentally be spread all over a code base. By creating one Custom Module per Object Type, we centralize handling for events for those objects.

Why?

Centralized event handling is easier to debug and abides by the principle of lease surprise because disabling a single module should disable all custom event handling for that object type.

Why?

If you have multiple custom Object Types in a Custom Module within the Admin UI, you should also co-locate these Object Types together in your code.

Single Module for all Workflow Events

Recommended

  • Create a single Custom Module class
  • Factor out specific Content Type event handling to separate non-static classes

Why?

Global events call their handlers in the order that they were registered, but this order can be very difficult to predict when event handlers are registered in different Custom Modules or across different assemblies. This becomes an issue when multiple event handlers might modify the same Content Item for a single Content Type event.

To make the order of executing of event handlers more predictable, register them all in a single class.

[assembly: RegisterModule(typeof(PageGlobalEventsModule))]

namespace Sandbox.Xperience.Admin;

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

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

        DocumentEvents.Insert.Before += Insert_Before;
        DocumentEvents.Delete.Before += Delete_Before;
        WorkflowEvents.SaveVersion.Before += SaveVersion_Before;
    }

    private void Insert_Before(object sender, DocumentEventArgs e)
    {
        new HomePageEventsHandler().Insert_Before(sender, e);
        new PublicPageEventsHandler(Log).Insert_Before(sender, e);
        new NavigationPageEventsHandler().Insert_Before(sender, e);
    }

    private void Delete_Before(object sender, DocumentEventArgs e)
    {
        // ...
    }

    private void SaveVersion_After(object sender, WorkflowEventArgs e)
    {
        // ...
    }

    private IEventLogService Log => Service.Resolve<IEventLogService>();
}

Why?

Event handler classes can be created for each Content Type (or each group of Content Types) and organized with those generated Content Type classes to make them easy to find. Making these handler methods non-static will also make them more testable and help avoid captured global state (static class fields or properties):

namespace Sandbox.Xperience;

public class PublicPageEventsHandler
{
    private readonly IEventLogService log;

    public PublicPageEventsHandler(IEventLogService log) => this.log = log;

    public void Insert_Before(object sender, DocumentEventArgs e)
    {
        if (!IsValidDocument(e.Node))
        {
            return;
        }

        SetDefaultValues(e.Node);
    }

    public void Update_Before(object sender, DocumentEventArgs e)
    {
        if (!IsValidDocument(e.Node))
        {
            return;
        }

        SetDefaultValues(e.Node);
    }

    private void SetDefaultValues(TreeNode node)
    {
        // ...
    }

    private bool IsValidDocument(TreeNode node) =>
        // ...
}

Represent “Well known” Objects with an Immutable Class

Suggested

  • Create an immutable class to hold the identifiers for the “well known” objects
  • “Well known” objects are ones the application depends on to run correctly and are referenced by name
  • Add static properties for each well known object
  • Add an All object to contain the set of all static property instances of the class
public class TopLevelPage
{
    public static TopLevelPage Home { get; } = new TopLevelPage(new Guid("..."));
    public static TopLevelPage ContactUs { get; } = new TopLevelPage(new Guid("..."));
    public static TopLevelPage Products { get; } = new TopLevelPage(new Guid("..."));

    public static TopLevelPage[] All { get; } = new[]
    {
        Home,
        ContactUs,
        Products
    };

    protected TopLevelPage(Guid nodeGUID) => NodeGUID = nodeGUID;

    public Guid NodeGUID { get; }

    public static implicit operator Guid(TopLevelPage page) => page.NodeGUID;
}

Why?

Guid values are stable across environments when using [Xperience’s CD tools for deployment]https://docs.xperience.io/x/YgaiCQ), so they are safe to hardcode into an application. Surrounding this value in an immutable class (one with a protected constructor and non-writable properties) ensures these values are not accidentally changed by the application and gives them friendly names for developers to refer to them by.

Why?

Creating an All property makes it easy to programmatically check all the static class instance properties. This is helpful for guarding against accidental deletion of objects the application requires to function correctly (see Protect Required Data from Deletion)

Protect Required Data from Deletion

Recommended

  • In a Custom Module handle the Delete Global Events for a type that the system depends on
  • Check if the object can safely be deleted
  • Cancel the event and throw an exception if it cannot be deleted
[assembly: RegisterModule(typeof(PageGlobalEventsModule))]

namespace Sandbox.Xperience;

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

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

        DocumentEvents.Delete.Before += Delete_Before;
    }

    private void Delete_Before(object sender, DocumentEventArgs e)
    {
        if (TopLevelPage.All.Any(p => p.NodeGUID == e.Document.NodeGUID))
        {
            e.Cancel();

            throw new Exception(
                $"Cannot delete Page [{e.Document.DocumentName}] because it is required by the application.")
        }
    }
}

Why?

It’s often necessary to depend on specific Settings, Content Items, and Custom Module data items in applications. Identifiers (code name or GUID) are often hard-coded in an application code base to make referring to this data easier (see Represent “Well known” Objects with an Immutable Class)

We need to prevent this data from being deleted accidentally and Global Events provide the most convenient way to intercept the deletion process and stop if the data needs to exist for the site to function properly.

Why?

Throwing an exception after canceling the deletion event will add an event in the Event Log explaining why the object could not be deleted. The message of the exception will also be displayed in the Administration application for users to view.

Custom Providers

  • Centralizes customizations
  • Inheritance model means composition is difficult

Register Xperience Customizations in applications

Suggested

  • When creating Custom Modules, Providers, or Decorated services, register them in a single place your application project (ex: Program.cs or a configuration class)
  • Avoid registering these types in the same file they are defined in, and also in any library project.
// Sandbox.Library/MyModule.cs

namespace Sandbox.Xperience;

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

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

        // ...
    }
}
// Sandbox.Web/Configuration/XperienceDpendencies.cs

[assembly: RegisterModule(typeof(MyModule))]
[assembly: RegisterModule(typeof(...))]
[assembly: RegisterModule(typeof(...))]

Why?

Kentico Xperience’s Custom Modules are a powerful tool for customizing the platform. These types, along with Custom Providers and decorated system services give developers plenty of flexibility for customizations. However, we cannot ‘register’ these customizations using the same dependency injection (DI) approach as we do with an ASP.NET Core application.

Why?

Xperience has its own DI container interally and this container initializes itself before ASP.NET Core - during the custom module PreInit() method call, so we cannot customize types in this container without using its API - the [assembly: ...] attribute.

These attributes are often displayed above the class that is being registered. If this class is in a class library, any consumer (like a console app) of this class library will automatically have all types registered that are defined in the library - there’s no way to opt-out.

Why?

By defining type registerations in an application, we leave it up to each library consumer to choose if they want to include a customization. Yes, this means enabling a customization takes more work, but it provides signficantly more flexibility. This also means consuming applications can compose/fully replace a customization.

Why?

Creating a dependency management class in each project provides a central location to check for and register all Kentico Xperience related dependencies.