Kentico Xperience Design Patterns: Good Startup.cs Hygiene
Sean G. Wright
Posted on February 1, 2021
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'sGlobal.asax.cs
andweb.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();
}
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();
// ...
}
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();
}
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"];
});
}
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();
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"];
});
}
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;
}
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);
}
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;
}
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();
// ...
}
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;
}
}
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()
// ...
}
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()
// ...
}
}
Ah, so fresh and so clean... š
As always, thanks for reading š!
References
- Xperience Docs: Starting with ASP.NET Core development
- .NET Docs: App startup in ASP.NET Core
- .NET Docs: Dependency injection in ASP.NET Core
- .NET Docs: ASP.NET Core Middleware
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.
Or my Kentico Xperience blog series, like:
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
March 14, 2022
November 8, 2021