Kentico Xperience Design Patterns: Good Startup.cs Hygiene

seangwright

Sean G. Wright

Posted on February 1, 2021

Kentico Xperience Design Patterns: Good Startup.cs Hygiene

The migration from Kentico CMS on .NET Framework to Kentico Xperience on ASP.NET Core means a completely new way of setting up our application.

Gone are the Global.asax.cs files with application events that we hook into. Gone are the web.config files with HttpModule and HttpHandler XML declarations.

Instead we have the all encompassing Startup.cs!

But is there a lesson we can learn from those ASP.NET files of old?

šŸ“š What Will We Learn?

  • The role of ASP.NET Core's Startup.cs compared to ASP.NET's Global.asax.cs and web.config
  • Using a fluent syntax to increase readibility
  • How to maintain DI (Dependency Injection) registration
  • How to maintain a middleware pipeline

šŸš€ What's a Startup.cs?

In versions of Kentico Xperience before 13.0, we had two primary files where the low level, functional configuration of our applications happened - the web.config and Global.asax.cs.

The web.config file let us register modules and handlers that became part of our request processing pipeline.

The Global.asax.cs file provided application lifecycle events we could hook into and with the advent of MVC it became a place to register MVC specific components.

Now, in ASP.NET Core, all of this has been replaced with the Startup.cs file and its two key methods, ConfigureServices() and Configure() šŸ˜®.

šŸ›’ ConfigureServices

ConfigureServices() is called first, and it does exactly what the name says - it is used to configure all the services we want to use in our application. Specifically, it gives us a place to register them with the dependency injection container.

If you are unfamiliar with dependency injection, check out my post ASP.NET Core: What is Dependency Injection?.

The registration of services in our dependency injection container happens in code, and this replaces the XML web.config registration of HttpHandlers and HttpModules from classic ASP.NET.

We can think of ConfigureServices() as the grocery store. Each of our services are things we are putting in our cart for checkout, which means in this analogy, the dependency injection container is the shopping cart šŸ›’.

"I want one of these šŸŽ and one of these šŸ‰ ... oh and I'll definitely need this šŸ°".

Continuing with shopping cart analogy, we can add items to our cart in any order, and similarly, we can register dependencies in the container in any order, because nothing is finalized until we get to the checkout line, which for us, is the end of the ConfigureServices() method.

We can call methods on the IServiceCollection type (most of which are extension methods) to add types explicitly, or configure and add the types from libraries we want to use with our application:

public void ConfigureServices(IServiceCollection services)
{
    services.AddKentico();
}
Enter fullscreen mode Exit fullscreen mode

In the example above, we call AddKentico() which is an extension method that does all the hard work šŸ’ŖšŸ½ of configuring and registering types from Xperience into our application for us to use.

šŸ² Configure

Configure() serves a very different role!

If ConfigureServices() is our trip to the grocery store, then Configure() is the recipe we follow when we get home and start cooking dinner šŸ².

Unlike a trip to the grocery store, where we can visit parts of the store and add items to our shopping cart in any order, recipes require exact steps in a specific order.

Configure() is a recipe šŸ“ƒ where we define all the middleware that gets to process incoming HTTP requests and their outgoing responses. And, middleware is what replaces the functionality HttpHandlers and HttpModules of classic ASP.NET.

public void Configure(IApplicationBuilder app)
{
    // Our static files will now only be served via https
    app.UseHttpsRedirection();

    app.UseStaticFiles();

    // ...
}
Enter fullscreen mode Exit fullscreen mode

If we try to chop the vegetables after cooking them, we're going to have a bad time.

If we make sure and put our UseHttpsRedirection middleware is before our UseStaticFiles middleware, then we can serve 301 responses for http requests to our static files and ensure they always serve correctly over https šŸ‘šŸ»!

šŸ’… Maintaining Our Startup.cs

If we look at an example web.config from a Kentico Xperience Content Management application, we can see they can be a bit overwhelming. This one is 324 lines long šŸ˜±!

To be fair, some of this file's content is for configuring IIS, some is application settings, and some is handling .dll version incompatibilities (which .NET Core / .NET 5 handles for us transparently).

But that's kind of beside the point because this file is too big and complex to be easily edited or compared in version control.

Let's keep this in mind as we look at an example Startup.cs to identify key issues:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IPaymentHelper, PaymentHelper>();
    services.AddScoped<IPaymentResponsitory, PaymentRepository>();

    services.AddLocalization(options => 
    {
        options.ResourcesPath = "Resources";
    });
    services.AddControllersWithViews(options =>
    {
        options.Conventions.Add(...);
        options.Conventions.Add(...);
    });

    services.AddOptions<PaymentApiKeys>().Configure(keys =>
    {
        keys.Secret = Configuration["payment:keys:secret"];
        keys.Public = Configuration["payment:keys:public"];
    });

    services.Configure<RouteOptions>(options =>
    {
        options.LowercaseUrls = true
    });

    var kentico = services.AddKentico(features =>
    {
        features.UsePageBuilder();
        features.UsePageRouting(new PageRoutingOptions
        {
            EnableAlternativeUrls = true,
            EnableRouting = true
        });
    });
    kentico.SetAdminCookiesSameSiteNone();
}
Enter fullscreen mode Exit fullscreen mode

Most Xperience applications with have a ConfigureServices() method far more complex than this one, which means my recommendations will be even more effective for those apps šŸ‘.

šŸ“‚ Organizing Our Concerns

First, we're mixing concerns and not setting a good pattern for future configuration.

We have some application service registration (IPaymentHelper) followed by ASP.NET Core localization and MVC conventiontion customization.

Then comes more application registration (PaymentApiKeys) and MVC configuration (RouteOptions), followed by Xperience integration (AddKentico(), SetAdminCookiesSameSiteNone()).

āš  We need to manage dependency registration early on in our development process, because it always grows in complexity and size.

Is it clear, when reading this file, what features our application has šŸ™„? Is it likely we'll need to scroll up and down the file to keep track of what's going on šŸ¤”?

Let's re-arrange our registration so that related types are grouped together.

While we're at it, let's also use the fluent syntax enabled by these extension methods (they all return IServiceCollection), and convert our statements to expressions!

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddLocalization(options => 
        {
            options.ResourcesPath = "Resources";
        })
        .Configure<RouteOptions>(options =>
        {
            options.LowercaseUrls = true
        })
        .AddControllersWithViews(options =>
        {
            options.Conventions.Add(...);
            options.Conventions.Add(...);
        });

    services
        .AddKentico(features =>
        {
            features.UsePageBuilder();
            features.UsePageRouting(new PageRoutingOptions
            {
                EnableAlternativeUrls = true,
                EnableRouting = true
            });
        })
        .SetAdminCookiesSameSiteNone();

    services
        .AddSingleton<IPaymentHelper, PaymentHelper>()
        .AddScoped<IPaymentResponsitory, PaymentRepository>()
        .AddOptions<PaymentApiKeys>().Configure(keys =>
        {
            keys.Secret = Configuration["payment:keys:secret"];
            keys.Public = Configuration["payment:keys:public"];
        });
}
Enter fullscreen mode Exit fullscreen mode

Our reorganization is looking good šŸ¤©! We've chained our registrations together, which reduces the syntax noise in the file, and we've grouped our registrations together into related groups.

We start at the low level parts of the application - the ASP.NET Core configuration and move on to Xperience and then our custom application types.

šŸ— Extracting To Extensions

The next step step in making sure our Startup.cs is well maintained is the practice of moving our configuration out of Startup.cs.

Startup.cs is the root of our configuration, but that doesn't mean it needs to be the source of it.

It's too easy to let this file become a big ball of mud šŸ’© where we continually patch in new functionality until it grows so large that no one can figure out where to make changes or what is going on šŸ˜µ.

I like the pattern of creating a /Configuration folder at the root of the application and adding extension method classes there, which I then call in Startup.cs.

Let's try that below:

public void ConfigureServices(IServiceCollection services) =>
    services
        .AddAppMvc()
        .AddAppXperience()
        .AddApp();
Enter fullscreen mode Exit fullscreen mode

Each section that was in our ConfigureServices() method has been moved out into other files, and we've named our new extension methods after the sections that used to be here.

I like adding the App prefix to these extension methods to prevent them from conflicting with anything supplied by a library. Use any convention you want, but stick to it šŸ˜€.

It's now very clear, at a high level, what parts our application is using and customizing - Mvc, Kentico Xperience, and our own application code.

As we add more integrations, like ASP.NET Core Identity, we'll add new extension classes, and Startup.cs starts to look like a table of contents šŸ“‘, rather than the first 100 pages, of a book šŸ“—.

We've even changed to the expression bodied method syntax, getting rid of a couple curly braces šŸ§.

I think this is a small, but important change.

šŸ˜ A Digression on Expressions

Expression bodied members require expressions and cannot be used with statements.

By not allowing statements in our ConfigureServices() method, we are forcing new service registrations to follow the pattern of being organized in an existing (or new) extension method class šŸ¤“, and not in the ConfigureSerivces() call.

Just for reference, let's look at the definition of the AddApp() method that we're now using:

// ~/Configuration/AppConfiguration.cs

public static class AppConfiguration
{
    public static IServiceCollection AddApp(
        this IServiceCollection services) => 

        services
        .AddSingleton<IPaymentHelper, PaymentHelper>()
        .AddScoped<IPaymentResponsitory, PaymentRepository>()
        .AddOptions<PaymentApiKeys>().Configure(keys =>
        {
            keys.Secret = Configuration["payment:keys:secret"];
            keys.Public = Configuration["payment:keys:public"];
        });
}
Enter fullscreen mode Exit fullscreen mode

Even here, we continue the use of expression bodied methods, and enable a fluent syntax by returning IServiceCollection so that the next extension method can chain off this one.

With these configuration classes, it's much more clear where to add the next type registration or configuration. Each file is smaller which means it's easier to read and merge in source control.

ā›µ Staying Fluent

Those familiar with Xperience on ASP.NET Core might have noticed that some of our fluent method chaining doesn't always work how we'd like.

AddControllersWithViews() returns an IMvcBuilder type instead of IServiceCollection and Xperience's SetAdminCookiesSameSiteNone() returns a IKenticoServiceCollection.

If these appear at the end of our method chaining, we aren't going to be able to return IServiceCollection, which means we can't have the nice services.AddAppX().AddAppY().AddAppZ(); calls in our Startup.us, that we saw above šŸ˜¢.

How do we return IServiceCollection without statements like return services? What if we want to make as much of our configuration expression based and fluent as possible?

Fortunately, we can stay in the land of fluent expressions a few ways.

For the case of AddControllersWithViews(), which returns IMvcBuilder, we can make a new extension method (extension-ception!) that gives us exactly what we want:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection ToServiceCollection(
        this IMvcBuilder _, 
        IServiceCollection services) => services;
}
Enter fullscreen mode Exit fullscreen mode

That's it! We discard the IMvcBuilder because we're done with it and return the IServiceCollection that's passed in as a parameter.

In practice it looks like this:

// ~/Configuration/MvcConfiguration.cs

public static class MvcConfiguration
{
    public static IServiceCollection AddAppMvc(
        this IServiceCollection services) =>

        services
            .AddLocalization(options => 
            {
                options.ResourcesPath = "Resources";
            })
            // ... more registrations
            .AddControllersWithViews(...)
            .ToServiceCollection(services);
}
Enter fullscreen mode Exit fullscreen mode

For SetAdminCookiesSameSiteNone() which returns an IKenticoServiceCollection, we can rely on the fact that the Xperience development team was kind šŸ™ enough to expose the inner IServiceCollection as a .Service property:

// ~/Configuration/XperienceConfiguration.cs

public static class XperienceConfiguration
{
    public static IServiceCollection AddAppXperience(
        this IServiceCollection services) =>

        services
            .AddKentico(...)
            .SetAdminCookiesSameSiteNone()
            .Services;
}
Enter fullscreen mode Exit fullscreen mode

Easy peasy šŸ˜Ž.

šŸšæ Configuring our Pipeline

It's always going to be the case that our ConfigureServices() method is going to require a lot more coding that our Configure() method, but that doesn't mean there aren't good practices for keeping Configure() well maintained šŸ˜.

šŸ’Ø Environmental Conditions

If we want to apply the same fluent expression bodied method approach to our middleware pipeline that we applied to our service registration, we're going to quickly run into a problem šŸ˜‘.

Every ASP.NET Core application has a Configure() method that starts with the following:

public void Configure(
    IApplicationBuilder app, 
    IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();
    app.UseStaticFiles();

    // ...
}
Enter fullscreen mode Exit fullscreen mode

In C#, conditionals are almost always statements rather than expressions, and this if() block is exactly the type of code that stops an expression based fluent syntax in its tracks.

However, here again, we can rely on a custom extension method to turn our statement into a chainable method call:

public static class ApplicationBuilderExtensions
{
    public static IApplicationBuilder IfDevelopment(
        this IApplicationBuilder builder, 
        IWebHostEnvironment env,
        Action<IApplicationBuilder> operation)
    {
        if (env.IsDevelopment() && operation is not null)
        {
            operation(builder);
        }

        return builder;
    }
}
Enter fullscreen mode Exit fullscreen mode

This simple extension lets us conditionally add middleware but as a fluent method. We keep chaining, and the middleware will only be added to the pipeline in the Development environment.

Let's return to our previous example to see how it would be used:

public void Configure(
    IApplicationBuilder app, 
    IWebHostEnvironment env) =>

    app
      .IfDevelopment(env, a => a.UseDeveloperExceptionPage())
      .UseHttpsRedirection()
      .UseStaticFiles()
      // ... 
}
Enter fullscreen mode Exit fullscreen mode

We now have a clean middleware setup, with an environment dependent pipeline, but no statements šŸ’ŖšŸ¼.

šŸ§  Conclusion

The Startup.cs file we started with was destined to become an unmaintainable dumping ground šŸ’© for the service registration and middleware pipeline configuration šŸ¤¦šŸ½ā€ā™€ļø.

By establishing some conventions and identifying patterns, we extracted common behavior to other classes.

To get around technical issues with our desired fluent syntax, we created some helpful extension methods.

What we end up with is a Startup.cs that will grow with our application but also read like a table of contents for its core functionality šŸ§:

public class Startup
{
    public Startup(...)
    {
        // ...
    }

    public void ConfigureServices(IServiceCollection services) =>
        services
            .AddAppMvc()
            .AddAppXperience()
            .AddApp();

    public void Configure(
        IApplicationBuilder app,
        IWebHostEnvironment env) =>

        app
          .IfDevelopment(env, a => a.UseDeveloperExceptionPage())
          .UseHttpsRedirection()
          .UseStaticFiles()
          // ... 
    }
}
Enter fullscreen mode Exit fullscreen mode

Ah, so fresh and so clean... šŸ˜Ž

As always, thanks for reading šŸ™!


Photo by Markus Spiske on Unsplash

References


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 or Xperience tags here on DEV.

#kentico

#xperience

Or my Kentico Xperience blog series, like:

šŸ’– šŸ’Ŗ šŸ™… šŸš©
seangwright
Sean G. Wright

Posted on February 1, 2021

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

Sign up to receive the latest update from our blog.

Related