Kentico 12: Design Patterns Part 20 - Choosing a Solution Architecture

seangwright

Sean G. Wright

Posted on February 3, 2020

Kentico 12: Design Patterns Part 20 - Choosing a Solution Architecture
Photo by Paul-Louis Prรถve on Unsplash

Table of Contents

What's a Solution Architecture?

For a .NET developer, a solution architecture is the pattern that defines the way projects and classes are organized in our Visual Studio solution.

This includes how things are named and also the relationships between projects (the project-to-project dependencies) and classes (abstractions or dependencies) ๐Ÿค”.

The choices we make in our solution architecture, when we start building a new Kentico EMS application, can have significant impacts on many things ๐Ÿง:

  • Time to completion of the project
  • Ease of on-boarding of developers
    • New team members
    • Anyone tasked with quickly diagnosing and solving problems
    • Ourselves in the future when we've forgotten everything
  • Maintainability of the code base
    • Prevention of regressions when introducing new features or corrections
  • Flexibility of the application to handle changes
    • New business requirements
    • Upgrades
    • Introductions of new technology
  • Testability (both manual and automated)

We cannot have the ideal amounts of everything above ๐Ÿ˜Ÿ.

Some of the choices that would improve testability can increase development time and cost.

A quickly built website may end up being un-maintainable, and require a re-write if the business needs substantial functionality changes.

At an even higher level, choosing the right (and potentially different) approach for each project is more flexible, but could make it more difficult for team members to move between projects if we build multiple websites.

For any agencies developing with Kentico, this is likely something you will want to discuss before choosing an architecture.


The Foundation with Kentico 12 MVC

Before we begin analyzing our options for solution architecture, we should establish the foundation defined for us when we decide to create a Kentico 12 MVC site ๐Ÿ‘.

Take a look at Kentico's documentation on the differences between the Portal Engine and MVC development models and also the description of Kentico's MVC implementation if you haven't already.

Filesystem structure of a fresh Kentico 12 MVC install

Above we see what the Kentico Installation Manager (KIM) creates when we choose to install a brand new Kentico 12 MVC code base.

We can note a couple things about the solution architecture we are provided:

  1. There are (2) projects:
    • โœ” Content Management - identified by the CMS folder, this is the same Kentico Web Forms application we've been using for years to manage and maintain our content.
    • โœ” Content Delivery - identified by the Sandbox folder, this is a standard empty ASP.NET MVC 5 codebase with only a few extra lines sprinkled in for Kentico EMS integration.
  2. Besides the "MVC" project, the directory looks identical to what we'd see if we had a new Portal Engine site.

From here on I'm going to refer to the "MVC" project as Content Delivery and the "CMS" project as Content Management. This is a differentiation that exists in many other CMS products and frameworks, and I think it's a valuable one ๐Ÿ˜‰.

It also avoids tying us to a specific technology - couldn't our Content Delivery be built on Web API 2 and a React client application? We don't call the CMS project by the technology (Web Forms) it is built on ๐Ÿค”!

What this means is that we are already working with, at least, a (2) project solution, whereas with previous Kentico Portal Engine sites, we could build everything with (1) project.

We can no longer just think about our project as "My app manages and displays content". Instead we have a clear separation of "Content Management" and "Content Delivery" ๐Ÿ˜ฎ (though we can blur those lines with shared libraries, as we will see going forward).

For all the options below, I recommend adding both the Content Management and Content Delivery projects to the same solution file, if only so you don't have to keep (2) instances of Visual Studio running during development ๐Ÿค—.

I am also going to present all architectures as using Feature Folders for file organization ๐Ÿ†, which I detailed earlier in my Design Patterns series:

Let's now take a look at our solution architecture options.


Solution Architectures

No Abstractions, Single Layer

The simplest solution architecture takes what we are given "out of the box" and makes very few modifications to it.

I'm calling this architecture "No Abstractions, Single Layer" because we are not creating any custom abstractions (beyond what we get from Kentico) and we only have the (2) .NET projects we start out with.

We create custom Page Types and use them to retrieve data in our Content Delivery project.

Class organization and naming are still important, but we don't need to separate our solution into additional new projects because there is no shared code - we can consider the Content Management part of our code, complete, and we won't make modifications to that project ๐Ÿ˜„.

Content Delivery project using Feature Folders organization

Above we can see how each Feature (in this case, each page), has its own folder and all the classes that implement that feature are contained within it. Classes used for rendering the View (HomeController, HomeViewModel) are next to classes used for data access (HomePageProvider, HomePage).

Below we can see what a very simplistic implementation of our HomeController might be. It includes data access directly in the Index action method, but still creates a HomeViewModel to pass data to the Razor view.

We still create a view model because this post is about making trade-offs, not cutting corners ๐Ÿ˜Ž.

// HomeController.cs

public class HomeController : Controller
{
    [HttpGet]
    public ActionResult Index()
    {
        var page = HomePageProvider.GetHomePages()
            .OnSite(SiteContext.CurrentSiteName)
            .Culture(LocalizationContext.CurrentCulture.CultureCode)
            .CombineWithDefaultCulture()
            .TopN(1)
            .TypedResult
            .FirstOrDefault();

        if (page is null)
        {
            return HttpNotFound("Could not find page ๐Ÿคท๐Ÿฟโ€โ™€๏ธ");
        }

        var viewModel = new HomeViewModel
        {
            Text = page.Fields.Text
        };

        return View(viewModel);
    }
}
Enter fullscreen mode Exit fullscreen mode

What if we want to leverage caching and Kentico's powerful MVC-based Page Builder functionality ๐Ÿค”?

Well, since we are keeping this as simple as possible, we will implement these things in our controller action method:

public class HomeController : Controller
{
    [HttpGet]
    public ActionResult Index()
    {
        var page = CacheHelper.Cache(() =>
            HomePageProvider.GetHomePages()
                .OnSite(SiteContext.CurrentSiteName)
                .Culture(LocalizationContext.CurrentCulture.CultureCode)
                .CombineWithDefaultCulture()
                .TopN(1)
                .TypedResult
                .FirstOrDefault(),
            new CacheSettings(1, nameof(HomeController), nameof(Index)));

        if (page is null)
        {
            return HttpNotFound("Could not find page ๐Ÿคท๐Ÿฟโ€โ™€๏ธ");
        }

        HttpContext.Kentico().PageBuilder().Initialize(page.DocumentID);

        var viewModel = new HomeViewModel
        {
            Text = page.Fields.Text
        };

        return View(viewModel);
    }
}
Enter fullscreen mode Exit fullscreen mode

We can already see that if we continue to add functionality (marketing automation, logging, multiple data sources) to the home page, we are going to end up with a large and complex controller ๐Ÿ˜–.

This approach is often referred to as the Fat Controller and is considered an anti-pattern.

It's comparable to placing all our code in a Web Forms page code-behind when building a Kentico Portal Engine site ๐Ÿ’ฃ.

There are ways we can help solve the increasing complexity of our controller with abstractions and infrastructure. That said, if we need to grow the code base we will run into additional architecture problems.

Let's summarize the benefits and problems with this approach:

  • Pros

    • Extremely quick to implement
    • Easy to locate the right classes
    • No project dependencies (simple builds)
    • Few abstractions or layers ("just look in the controller!")
    • Changes to our Page Types propagate through the code quickly (minimal data mapping)
  • Cons

    • Difficult or impossible to unit test (due to direct use of *Context, CacheHelper and data access in the controller)
    • Cross-cutting concerns (caching, logging, Page Builder) are not DRY
    • Lack of abstractions means the controller has to know how to do everything
    • Growth in controller complexity is not sustainable

With this solution architecture we could probably build a site extremely quickly, and that might be its best sales-pitch ๐Ÿ’ฐ.

It does, however, come with all the caveats, our industry has discussed for years, that show up when we build the entire application in the view layer ๐Ÿ’ฉ.


Multiple Abstractions, Single Layer

Moving on from the simplest approach, we next see the design that Kentico uses in its DancingGoat demo site ๐Ÿค“.

This architecture has plenty of infrastructure and abstractions, but continues with a single layer of projects (Content Management and Content Delivery).

The DancingGoat sample project uses the traditional MVC class organization that we get when creating a blank MVC project (File -> New Project) in Visual Studio. I'm not a fan of this organization (I call it "Framework Features" organization, instead see "Feature Folders" above).

So, what are the key differences we see with the "Multiple Abstractions, Single Layer" architecture and the previous one?

Let's look at the HomeController code as an example:

public class HomeController : Controller
{
    private readonly IHomeRepository homeRepository;

    public HomeController(IHomeRepository homeRepository)
    {
        if (homeRepository is null)
        {
            throw new System.ArgumentNullException(nameof(homeRepository));
        }

        this.homeRepository = homeRepository;
    }

    [HttpGet]
    public ActionResult Index()
    {
        var page = homeRepository.GetHomePage();

        if (page is null)
        {
            return HttpNotFound("Could not find page ๐Ÿคท๐Ÿฟโ€โ™€๏ธ");
        }

        HttpContext.Kentico().PageBuilder().Initialize(page.DocumentID);

        var viewModel = new HomeViewModel
        {
            Text = page.Fields.Text
        };

        return View(viewModel);
    }
}
Enter fullscreen mode Exit fullscreen mode

Looking at this code, we can see that we have a constructor dependency, IHomeRepository, in the HomeController, which handles getting data from our data source.

// IHomeRepository.cs

public interface IHomeRepository
{
    HomePage GetHomePage();
}
Enter fullscreen mode Exit fullscreen mode
// HomeRepository.cs

public class HomeRepository : IHomeRepository
{
    public HomePage GetHomePage() =>
        CacheHelper.Cache(() =>
            HomePageProvider.GetHomePages()
                .OnSite(SiteContext.CurrentSiteName)
                .Culture(LocalizationContext.CurrentCulture.CultureCode)
                .CombineWithDefaultCulture()
                .TopN(1)
                .TypedResult
                .FirstOrDefault(),
            new CacheSettings(1, nameof(HomeRepository), nameof(GetHomePage)));
}
Enter fullscreen mode Exit fullscreen mode

Page Builder functionality is still handled in the controller and the Kentico custom Page Types are exposed to the view layer, via the IHomeRepository.

I consider all the pieces of the MVC paradigm (Models, Views, Controllers) to be concerns of the View layer. So anything we have access to in a controller is exposed to the view layer.

With the addition of abstractions we can have the possibility of automated unit tests. The more abstractions we add, the easier it is to unit test the parts of our application in isolation ๐Ÿ˜.

While the solution architecture has changed, these new abstractions are not being shared with the Content Management application and there are no shared libraries.

Solution view of the Sandbox project with a repository interface and class added for the Home feature

Let's summarize the benefits and problems with this approach:

  • Pros

    • Still relatively quick to implement
    • Easy to locate the right classes (feature folders, single project)
    • No project dependencies (simple builds)
    • Changes to our Page Types propagate through the code quickly (weak abstraction layers)
    • Parts of our application can be verified by automated tests
    • Cross-cutting concerns (caching, logging, Page Builder) can be DRY by applying them to abstractions
    • More flexible application architecture
  • Cons

    • Requires setting up a composition root and thinking differently about dependencies
    • Abstractions cannot be shared with the Content Management application
    • The flexibility that abstractions create also introduces some complexity
    • Controllers still have to know about data access (custom Page Type classes) types

To learn about how we can take advantage of abstractions for Cross-cutting concerns, see my post Kentico 12: Design Patterns Part 12 - Database Query Caching Patterns



N-Tier Architecture - Layers as Abstractions

Once we begin to focus on our abstractions we see that the most obvious and common area of abstractions is in data access - especially in a Kentico application ๐Ÿง.

The next evolution of our application architecture moves data access into its own project layer.

This results in the classic N-Tier Layered Architecture for the solution:

Solution view of Sandbox projects which includes a new Sandbox.Data project

Above, we can see a new Sandbox.Data project where all the data access code lives. The Kentico generated HomePage type has been moved to this project, along with the repository abstraction and implementation.

The Sandbox.Data project will need to add the Kentico.Libraries NuGet package as a dependency to enable the underlying Kentico database access types.

In addition to the move of these files from the view-layer-focused Sandbox project, we have added a new HomePageData class which helps preserve the boundary between the view layer and the data access layer ๐Ÿ˜„.

Let's look at how our Home feature classes have changed:

// HomePageData.cs

public class HomePageData
{
    public int DocumentId { get; set; }
    public string Text { get; set; }
}
Enter fullscreen mode Exit fullscreen mode
// IHomeRepository.cs

public interface IHomeRepository
{
    HomePageData GetHomePage();
}
Enter fullscreen mode Exit fullscreen mode
// HomeRepository.cs

public class HomeRepository : IHomeRepository
{
    public HomePageData GetHomePage() =>
        CacheHelper.Cache(() =>
            HomePageProvider.GetHomePages()
                .OnSite(SiteContext.CurrentSiteName)
                .Culture(LocalizationContext.CurrentCulture.CultureCode)
                .CombineWithDefaultCulture()
                .TopN(1)
                .Columns(
                    nameof(HomePage.DocumentID),
                    nameof(HomePage.HomePageText)
                )
                .TypedResult
                .Select(page => new HomePageData
                {
                    DocumentId = page.DocumentID,
                    Text = page.Fields.Text
                })
                .FirstOrDefault(),
            new CacheSettings(1, nameof(HomeRepository), nameof(GetHomePage)));
}
Enter fullscreen mode Exit fullscreen mode

The addition of the HomePageData class means we gain a couple things:

  1. โœ” The "View" layer doesn't have to interact with Kentico's data access technology for unit testing.
  2. โœ” We can be more specific in our querying because we know exactly how much we expose to consumers of the IHomeRepository.
  3. โœ” The Content Management application could consume our Sandbox.Data project if there are common ways of querying and updating data in our data access, or business logic implementations.

From the points above, 1) is great for being able to focus on verifying the quality and accuracy of our code and prevent regressions since it makes testing easier ๐Ÿ‘๐Ÿฟ.

2) reduces flexibility of the application in some ways (we now have 2 classes to map our data to HomeViewModel and HomePageData), while increasing it in others (we can make optimizations and changes in the data access without affecting consumers) ๐Ÿ‘.

3) is helpful if we have a need for code re-use at the data access level, and many larger projects do see these opportunities (ex: Scheduled Tasks, Global Events) ๐Ÿค“.

Our HomeController code is effectively unchanged since we had already abstracted away the querying details in our Multiple Abstractions, Single Layer architecture.

  • Pros
    • Somewhat easy to locate the right classes (feature folders and abstractions co-located with implementations, but there are multiple projects)
    • Data access logic is easier to optimize
    • Our View layer has no dependencies on Kentico data access, making automated unit testing much simpler
    • Cross-cutting concerns (caching, logging, Page Builder) can be DRY by applying them to abstractions
    • Data access code can be re-used in the Content Management application
    • More flexible application architecture
  • Cons
    • Takes longer to implement since more preparation and design is required up-front
    • More complex builds and project dependencies requires a better understanding of how Visual Studio and .NET work
    • Requires setting up a composition root and thinking differently about dependencies
    • Changes to our Page Types propagate through the code slowly because the abstractions are stronger (we have to update 3 "data transfer" classes with each Page Type change)

A Brief Discussion of N-Tier Architectures

If we look at what we've created, it's not too hard to see that the entire Sandbox.Data project is layer and also an abstraction. It exposes new types (HomePageData) to the consuming layer above it (Sandbox).

With the N-Tier Architecture, we can add as many layers as we need, which means a new abstraction for each layer.

Imagine we need to add a layer of complex business logic in a Sandbox.Service project.

Sandbox.Service exposes IHomeService and implements this interface through HomeService. HomeService has a dependency on IHomeRepository to get its data. IHomeService is then consumed by HomeController.

HomeController -> IHomeService -> IHomeRepository (-> means "depends on")

N-Tier Architecture diagram with multiple application layers

Each layer we add brings its own abstractions and implementations and the direction of dependencies of these layers is from the top (View layer) down (to the data layer) ๐Ÿค”.

Classic N-Tier applications didn't use interfaces and so the layers were very tightly coupled, with the View layer being coupled to the data access layer through transitive dependencies ๐Ÿ˜จ.

But, we've taken advantage of interfaces so we've escaped that tight coupling, right?

Technically yes, but mentally no ๐Ÿ˜•.

We are still looking at the application as layers stacked on each other with the database being the foundation. This mental model will inevitably lead us into decisions that reinforce this perspective and potentially introduce coupling (perhaps through leaky abstractions) that we did not intend ๐Ÿคฏ.

N-Tier Architectures work fine for a large number of applications, especially when the layers are clearly defined and abstractions are introduced to lessen coupling ๐Ÿ‘.

However, there is one more evolution of our solution architecture to help resolve this issue.


Onion Architecture - Layers as Implementations

We've already created our abstractions and implementations, so this final change will focus more on their separation.

The N-Tier Architecture has us looking at our application as a vertical set of layers, with each layer depending on those below it. This treats the layers as abstractions.

The Onion Architecture treats layers as implementations because it places the abstractions and core "domain" data classes at the center of multiple layers, like an onion ๐Ÿค“.

Concentric circles showing an onion architecture with dependencies in the outer layers and abstractions in the middle

Above we can see that all the interfaces (including our made up IHomeService) are contained within the center of the architecture, whereas the implementations are all in the outer layers.

It might be strange to think about the View layer being the same as the Data Access layer, but this mental shift brings some benefits.

With an Onion Architecture we stop thinking of the database as the foundation or center of the our world and instead we have to fill that void with abstractions and models of our data ๐Ÿ™‚.

For smaller and less complex applications this is definitely overkill ๐Ÿ˜….

For larger ones that source data from multiple locations (web service, remote database, local database, file system, ect...) or have complex business rules about how that data is retrieved and modified, then focusing on the shape of the data that flows through the application and the abstractions that work with that data, independent of their implementation, helps us manage complexity and maintain flexibility ๐Ÿ˜Ž.

Let's implement this approach in our Sandbox solution:

Onion architecture in the Visual Studio solution

Above we can see that the IHomeRepository and HomePageData have been moved to a new project Sandbox.Core. This project only includes models and interfaces ๐Ÿ˜ฎ.

We've also changed the implementation details of HomePageData, treating it as a readonly object once created that also protects its internal state with constructor guards:

public class HomePageData
{
    public int DocumentId { get; }
    public string Text { get; }

    public HomePageData(
        int documentId,
        string text)
    {
        if (text is null)
        {
            throw new System.ArgumentNullException(nameof(text));
        }

        DocumentId = documentId;
        Text = text;
    }
}
Enter fullscreen mode Exit fullscreen mode

Since this type has become the core package of data passed around our application, we want to ensure it can't be created incorrectly or modified in a way that puts it into an invalid state ๐Ÿ’ช.

If we look at the dependencies of Sandbox.Core we can see there are almost none, especially dependencies like Kentico libraries that define implementation details:

Sandbox.Core expanded References node in Visual Studio

Our Sandbox.Data project depends on Sandbox.Core for the IHomeRepository interface and HomePageData model class.

The Sandbox project depends on Sandbox.Core as well, for the IHomeRepository interface, which is still a constructor dependency of HomeController.

While, Sandbox will have to take a dependency on Sandbox.Data to configure the composition root (likely for an Inversion of Control container library like Autofac), we won't expose the types of Sandbox.Data anywhere - it has now become an implementation detail unto itself ๐Ÿ‘.

The rest of our code remains unchanged with the exception of some namespace updates to reflect moving some types to Sandbox.Core.


Now that we've identified our abstractions and pushed the implementation details to the outside of our "onion", we are in good position to review the requirements of our application and start identifying other areas that could be moved around.

What's part of our core? What's part of the outer shell? What should be in the middle ๐Ÿค”?

These questions are not always easy to answer, but by changing the way we think about the architecture and patterns of our application, adopting an inside -> out approach with an Onion Architecture, we are in a good place to consider them ๐Ÿ‘๐Ÿฟ.

  • Pros
    • Data access logic is easier to optimize
    • Our View layer has no dependencies on Kentico data access, making automated unit testing much simpler
    • Our business logic (if it exists) has no dependencies on implementations (View layer, Data Access, Web Service technologies), making it very easy to unit test
    • Cross-cutting concerns (caching, logging, Page Builder) can be DRY by applying them to abstractions
    • Data access code can be re-used in the Content Management application
    • Extremely flexible and robust application architecture that forces us to focus on abstractions and data flow and consitency
  • Cons
    • Separating abstractions from implementations makes implementations harder to locate
    • Takes the longest to implement since we think about abstractions and models instead of technologies and implementations
    • More complex builds and project dependencies requires a better understanding of how Visual Studio and .NET work
    • Requires setting up a composition root and thinking differently about dependencies
    • Changes to our Page Types propagate through the code slowly because the abstractions are stronger

Conclusion

We've covered a lot of ground in this post ๐Ÿ˜…!

We started out reviewing what a Solution Architecture really is and why the choices we make around this architecture should matter to us, touching on a few examples of the impact that our decisions could have.

Then we looked at the foundation of our Kentico 12 MVC projects - the separation of Content Management from Content Delivery. This set the stage for the different architectures that were proposed.

The No Abstractions, Single Layer architecture was the simplest and fastest to implement. It's a great option for small sites with limited complexity that need to be built quickly ๐Ÿค—.

The Multiple Abstractions, Single Layer design can be seen in Kentico's DancingGoat demo site. This architecture allows for plenty of complexity and abstraction ๐Ÿ˜Š, but still treats the Content Delivery application as a homogeneous unit.

Many sites will probably start out using this approach but then migrate into the N-Tier - Layers as Abstractions architecture as they mature.

The creation of separate .NET projects for layers of our application enables code re-use in the Content Management app and also places a strong focus on our abstractions ๐Ÿ‘๐Ÿฟ.

The N-Tier Architecture is a classic approach which models where most applications end up. There are variations of it, but the consistent factor in all of them is that Data Access is the foundation of the application, and the View or Presentation layer is the top.

Finally, we looked at Layers as Implementations with Onion Architectures, as an alternative to building a Data Access centered application.

By moving all of our implementations to the outer shell of our architecture and then focusing on abstractions and data models as our Core, we end up with a design that forces us to consider, more flexibly, how everything fits together ๐Ÿ’ช.

If you have any questions about the architectures we reviewed or how to implement the patterns, leave a comment below.

As always, thanks for reading ๐Ÿ™!


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

If you are looking for additional Kentico content, checkout the Kentico tag here on DEV:

#kentico

Or my Kentico blog series:

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

Posted on February 3, 2020

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

Sign up to receive the latest update from our blog.

Related

ยฉ TheLazy.dev

About