← Back to knowledge base |
  • Xperience by Kentico

Guide to Repository pattern with a Generic type in Xperience by Kentico

The Repository pattern in C# is a design pattern used to manage data retrieval from a data source. It acts as a mediator between the data access layer and the business logic layer of an application. When working with websites built on Xperience by Kentico, we often focus on fetching data from the database, and this is where the repository pattern becomes invaluable. In this guide, I'll walk you through the process of creating repositories that retrieve data from the Xperience by Kentico database. We'll use generic types and focus on Reusable content and Pages content types. This approach simplifies data access and enhances your website's performance and maintainability.

Solution outline

In our final solution, we'll have four key files:

  • ContentRepositoryModel.cs - This file outlines the types for method parameters used across the other files.
  • WebPageRepository.cs and ReusableContentRepository.cs - These files implement the methods that define data fetching queries, cache keys, and cache dependencies.
  • ContentRepositoryBase.cs - This file handles the actual data fetching, including data cache management.

In a Kentico Xperience project, these files typically reside in the Models folder. They sit alongside the types that are generated from your content types, which are defined in the Kentico Xperience admin interface.

Source code is also available as a Github gist.

The ContentRepositoryModel.cs file

The ContentRepositoryModel.cs file primarily defines types for method parameters used in other files, which we'll explore later in this article. I've chosen this approach over named parameters for clarity, especially as the number of parameters can increase as your site becomes more complex. Additionally, these grouped parameters, defined through types, are reused across multiple methods, promoting code consistency and maintainability.

using CMS.ContentEngine;
using CMS.Helpers;

namespace YourProject.Models;

public abstract class RepositoryParameters
{
    public CancellationToken CancellationToken { get; set; }
    public int LinkedItemsMaxLevel { get; set; } = 4;
}

public class GetByGuidsRepositoryParameters : RepositoryParameters
{
    public ICollection<Guid> Guids { get; set; }
}

public enum ContentItemType
{
    WebPage,
    ReusableContent,
}

public class GetCachedQueryResultParameters<T>
{
    public ContentItemQueryBuilder QueryBuilder { get; set; }
    public CacheSettings CacheSettings { get; set; }
    public Func<GetDependencyCacheKeysParameters<T>, Task<ISet<string>>> BuildCacheItemNames { get; set; }
    public ContentItemType ContentItemType { get; set; }
    public CancellationToken CancellationToken { get; set; }
}

public class GetDependencyCacheKeysParameters<T> : RepositoryParameters
{
    public IEnumerable<T> Items { get; set; }
}

The WebPageRepository.cs file

The WebPageRepository class implements a single public method that creates a data fetching query, data cache keys and dependencies. The actual implementation of the method may vary depending on your requirements. You can also create multiple methods like this if needed. My implementation fetches pages specified by their WebPageItemGUID for a specific web page content type. If no guid is provided, the method fetches all items of the specified type. 

using CMS.ContentEngine;
using CMS.DataEngine;
using CMS.Helpers;
using CMS.Websites;
using CMS.Websites.Routing;

namespace YourProject.Models;

public class WebPageRepository : ContentRepositoryBase
{
    private readonly IWebPageLinkedItemsDependencyAsyncRetriever webPageLinkedItemsDependencyAsyncRetriever;

    public WebPageRepository(IWebsiteChannelContext websiteChannelContext, IContentQueryExecutor executor, IProgressiveCache cache, IWebPageLinkedItemsDependencyAsyncRetriever webPageLinkedItemsDependencyAsyncRetriever)
        : base(websiteChannelContext, executor, cache)
    {
        this.webPageLinkedItemsDependencyAsyncRetriever = webPageLinkedItemsDependencyAsyncRetriever;
    }

    public async Task<IEnumerable<T>> GetPages<T>(GetByGuidsRepositoryParameters parameters = null) where T : IWebPageFieldsSource
    {
        if (parameters == null)
        {
            parameters = new GetByGuidsRepositoryParameters();
        }

        var typeName = typeof(T).FullName;
        var query = new ContentItemQueryBuilder()
            .ForContentType(typeName,
                config => config
                    .WithLinkedItems(parameters.LinkedItemsMaxLevel)
                    .OrderBy(OrderByColumn.Asc(nameof(IWebPageFieldsSource.SystemFields.WebPageItemOrder)))
                    .ForWebsite(WebsiteChannelContext.WebsiteChannelName)
                    .Where(parameters?.Guids != null ? where => where.WhereIn(nameof(IWebPageContentQueryDataContainer.WebPageItemGUID), parameters.Guids) : null))
            .InLanguage(WebsiteConstants.LANGUAGE_DEFAULT);

        var cacheSettings = new CacheSettings(WebsiteConstants.CACHE_MINUTES, WebsiteChannelContext.WebsiteChannelName, typeName, WebsiteConstants.LANGUAGE_DEFAULT, parameters.Guids != null ? parameters.Guids.Select(guid => guid.ToString()).Join("|") : "all");
        return await GetCachedQueryResult(new GetCachedQueryResultParameters<T>
        {
            QueryBuilder = query,
            CacheSettings = cacheSettings,
            BuildCacheItemNames = GetDependencyCacheKeys,
            ContentItemType = ContentItemType.WebPage,
            CancellationToken = parameters.CancellationToken
        });
    }

    private async Task<ISet<string>> GetDependencyCacheKeys<T>(GetDependencyCacheKeysParameters<T> parameters) where T : IWebPageFieldsSource
    {
        var dependencyCacheKeys =
            (await webPageLinkedItemsDependencyAsyncRetriever
                .Get(parameters.Items.Select(item => item.SystemFields.WebPageItemID), maxLevel: parameters.LinkedItemsMaxLevel))
                .ToHashSet(StringComparer.InvariantCultureIgnoreCase);

        foreach (var item in parameters.Items)
        {
            dependencyCacheKeys.Add(CacheHelper.BuildCacheItemName(new[] { "webpageitem", "bychannel", WebsiteChannelContext.WebsiteChannelName, "bycontenttype", typeof(T).FullName }, false));
        }

        return dependencyCacheKeys;
    }
}

Let's delve deeper into the code:

  • The WebPageRepository class extends the ContentRepositoryBase base class, sharing data fetching logic with the class defined in the ReusableContentRepository.cs file.
  • The constructor employs the dependency injection pattern.
  • The GetPages method:
    • Accepts a generic type T that represents a web page content type model. It must implement the IWebPageFieldsSource interface, enabling the repository to be used for any web page content type.
    • Constructs a query to retrieve content items from the database. In this case, we're requesting pages based on the content type represented by the T type, including linked items within a specified depth, ordered by the sequence defined in the Xperience by Kentico admin, and limiting the result items by their GUID.
    • Specifies the cache key's name and the duration for which the data should be cached, provided the cache revalidation is invoked by the cache dependency key specified in the GetDependencyCacheKeys method. This method also ensures that caching is managed for linked items.
    • Retrieves data by calling the GetCachedQueryResult method, which is defined in the base class. I will describe this method in more detail later in this article. 

The ReusableContentRepository.cs file

This file mirrors the repository implementation for web pages. While the purpose and logic remain largely the same, the implementation details vary slightly as it fetches data for reusable content items. 

using CMS.ContentEngine;
using CMS.Helpers;
using CMS.Websites.Routing;

namespace YourProject.Models;

public class ReusableContentRepository : ContentRepositoryBase
{
    private readonly ILinkedItemsDependencyAsyncRetriever linkedItemsDependencyRetriever;

    public ReusableContentRepository(
            IWebsiteChannelContext websiteChannelContext,
            IContentQueryExecutor executor,
            IProgressiveCache cache,
            ILinkedItemsDependencyAsyncRetriever linkedItemsDependencyRetriever)
            : base(websiteChannelContext, executor, cache)
    {
        this.linkedItemsDependencyRetriever = linkedItemsDependencyRetriever;
    }

    public async Task<IEnumerable<T>> GetItems<T>(GetByGuidsRepositoryParameters parameters = null) where T : IContentItemFieldsSource
    {
        if (parameters == null)
        {
            parameters = new GetByGuidsRepositoryParameters();
        }

        var typeName = typeof(T).FullName;
        var query = new ContentItemQueryBuilder()
                    .ForContentType(typeName,
                        config => config
                            .WithLinkedItems(parameters.LinkedItemsMaxLevel)
                            .Where(parameters.Guids != null ? where => where.WhereIn(nameof(IContentQueryDataContainer.ContentItemGUID), parameters.Guids) : null))
                    .InLanguage(WebsiteConstants.LANGUAGE_DEFAULT);

        var cacheSettings = new CacheSettings(WebsiteConstants.CACHE_MINUTES, WebsiteChannelContext.WebsiteChannelName, typeName, WebsiteConstants.LANGUAGE_DEFAULT, parameters.Guids != null ? parameters.Guids.Select(guid => guid.ToString()).Join("|") : "all");
        return await GetCachedQueryResult<T>(new GetCachedQueryResultParameters<T>
        {
            QueryBuilder = query,
            CacheSettings = cacheSettings,
            BuildCacheItemNames = GetDependencyCacheKeys,
            ContentItemType = ContentItemType.ReusableContent,
            CancellationToken = parameters.CancellationToken
        });
    }

    private async Task<ISet<string>> GetDependencyCacheKeys<T>(GetDependencyCacheKeysParameters<T> parameters) where T : IContentItemFieldsSource
    {
        var dependencyCacheKeys =
            (await linkedItemsDependencyRetriever
                .Get(parameters.Items.Select(item => item.SystemFields.ContentItemID), maxLevel: parameters.LinkedItemsMaxLevel))
                .ToHashSet(StringComparer.InvariantCultureIgnoreCase);

        foreach (var item in parameters.Items)
        {
            dependencyCacheKeys.Add(CacheHelper.BuildCacheItemName(new[] { "contentitem", "bychannel", WebsiteChannelContext.WebsiteChannelName, "bycontenttype", typeof(T).FullName }, false));
        }

        return dependencyCacheKeys;
    }
}

The ContentRepositoryBase.cs file

This file embodies the logic shared between the WebPageRepository.cs and ReusableContentRepository.cs files. It handles data retrieval, taking into account both preview data and data caching.

using CMS.ContentEngine;
using CMS.Helpers;
using CMS.Websites;
using CMS.Websites.Routing;

namespace YourProject.Models;

public abstract class ContentRepositoryBase
{
    protected IWebsiteChannelContext WebsiteChannelContext { get; init; }
    private readonly IContentQueryExecutor executor;
    private readonly IProgressiveCache cache;

    public ContentRepositoryBase(IWebsiteChannelContext websiteChannelContext, IContentQueryExecutor executor, IProgressiveCache cache)
    {
        WebsiteChannelContext = websiteChannelContext;
        this.executor = executor;
        this.cache = cache;
    }

    public Task<IEnumerable<T>> GetCachedQueryResult<T>(GetCachedQueryResultParameters<T> parameters)

    {
        if (parameters.QueryBuilder is null)
        {
            throw new ArgumentNullException(nameof(parameters.QueryBuilder));
        }

        if (parameters.CacheSettings is null)
        {
            throw new ArgumentNullException(nameof(parameters.CacheSettings));
        }

        if (parameters.BuildCacheItemNames is null)
        {
            throw new ArgumentNullException(nameof(parameters.BuildCacheItemNames));
        }

        return GetCachedQueryResultInternal(parameters);
    }

    private async Task<IEnumerable<T>> GetCachedQueryResultInternal<T>(GetCachedQueryResultParameters<T> parameters)
    {
        if (WebsiteChannelContext.IsPreview)
        {
            var queryOptions = new ContentQueryExecutionOptions()
            {
                ForPreview = true
            };
            return await GetMappedResultByContentItemType(parameters, queryOptions);
        }

        return await cache.LoadAsync(async (cacheSettings) =>
        {
            var result = await GetMappedResultByContentItemType(parameters);

            if (cacheSettings.Cached = result != null && result.Any())
            {
                cacheSettings.CacheDependency = CacheHelper.GetCacheDependency(await parameters.BuildCacheItemNames(new GetDependencyCacheKeysParameters<T>
                {
                    CancellationToken = parameters.CancellationToken,
                    Items = result
                }));
            }

            return result;
        }, parameters.CacheSettings);
    }

  private async Task<List<T>> GetMappedResultByContentItemType<T>(CachedQueryResultParameters<T> parameters, ContentQueryExecutionOptions queryOptions = null)
    {
        switch (parameters.ContentItemType)
        {
            case ContentItemType.ReusableContent:
                return (await executor.GetMappedResult<T>(parameters.QueryBuilder, queryOptions, cancellationToken: parameters.CancellationToken)).ToList();
            case ContentItemType.WebPage:
                return (await executor.GetMappedWebPageResult<T>(parameters.QueryBuilder, queryOptions, cancellationToken: parameters.CancellationToken)).ToList();
            default:
                throw new NotSupportedException($"ContentItemType '{parameters.ContentItemType}' is not supported. Use 'ReusableContent' or 'WebPage' instead.");
        }
    }
}

The GetCachedQueryResult method scrutinizes incoming parameters, while the actual logic unfolds in the GetCachedQueryResultInternal method:

  • If data retrieval occurs in the context of Preview mode or within a Page builder, it bypasses the data cache and always fetches fresh data.
  • Otherwise, it attempts to retrieve data from the data cache based on CacheSettings. If the data is not available in the cache, it requests them from the database and stores them in the cache for future use. 

Example of usage

This repository patter then of course could be used for retrieving web pages or content items in your controllers, widgets, or view components. 

Web page retrieval in a controller

using YourProject;
using YourProject.Controllers;
using YourProject.Models;

using Kentico.Content.Web.Mvc;
using Kentico.Content.Web.Mvc.Routing;

using Microsoft.AspNetCore.Mvc;

[assembly: RegisterWebPageRoute(Page.CONTENT_TYPE_NAME, typeof(PageController), WebsiteChannelNames = [WebsiteConstants.WEBSITE_CHANNEL_NAME])]

namespace YourProject.Controllers;

public class PageController : Controller
{
    private readonly WebPageRepository webPageRepository;
    private readonly IWebPageDataContextRetriever webPageDataContextRetriever;

    public PageController(WebPageRepository webPageRepository, IWebPageDataContextRetriever webPageDataContextRetriever)
    {
        this.webPageRepository = webPageRepository;
        this.webPageDataContextRetriever = webPageDataContextRetriever;
    }

    public async Task<IActionResult> Index()
    {
        var webPage = webPageDataContextRetriever.Retrieve().WebPage;

        var pages = await webPageRepository.GetPages<Page>(new GetByGuidsRepositoryParameters()
        {
            Guids = new List<Guid> { webPage.WebPageItemGUID },
            CancellationToken = HttpContext.RequestAborted
        });

        if (pages == null || !pages.Any())
        {
            return NotFound();
        }

        var page = pages.First();

        var model = PageViewModel.GetViewModel(page);

        return View(model);
    }
}

In the example, we define a controller for a page based on specific content type:

  • It retrieves context of the current page using webPageDataContextRetriever which includes the GUID we will uses for the data retrieval using the repository.
  • It retrieves the page by GUID using the repository and passing cancellation token that ensures freeing up resources when an ongoing request gets aborted.
  • If no page is retrieved, we consider it as page not found.
  • Otherwise, we build the view model and pass it in the view for final rendering.

Reusable content retrieval example

In a very similar fashion you can obtain content items using the repository:

...
await reusableContentRepository.GetItems<ReusableContent>(new GetByGuidsRepositoryParameters()
{
    Guids = guidsList,
    CancellationToken = HttpContext.RequestAborted
});
...

Conclusion

In conclusion, the outlined solution provides a robust and flexible approach to data retrieval in a Xperience by Kentico project. The four key files - ContentRepositoryModel.cs, WebPageRepository.cs, ReusableContentRepository.cs, and ContentRepositoryBase.cs - work in unison to define data fetching queries, manage data cache, and handle data fetching.

The ContentRepositoryModel.cs file provides a clear and maintainable way to define method parameters, while the WebPageRepository.cs and ReusableContentRepository.cs files implement the methods that define data fetching queries, cache keys, and cache dependencies. The ContentRepositoryBase.cs file, on the other hand, handles the actual data fetching, including data cache management.

This solution also takes into account the context of data retrieval, ensuring fresh data is fetched in Preview mode or within a Page builder, and otherwise retrieving data from the cache or database as needed.

Source code for this post is available as a Github gist.

 

About the author

Milan Lund is a Freelance Web Developer with Kentico Expertise. He specializes in building and maintaining websites in Xperience by Kentico. Milan writes articles based on his project experiences to assist both his future self and other developers.

Find out more
Milan Lund