Cleaning up Sitecore Controllers
Ever since the day I read the blog post by Paul Stovell about cleaning up ASP.NET MVC Controllers back in 2012, I knew the day would come where I wanted to clean-up my controllers. By introducing NitroNet for Sitecore in our projects, that day has finally arrived.
NitroNet for Sitecore brings at least two major changes to your development workflow:
- No more razor views
All views are build with handlebars. - Controller renderings only
View renderings are not supported by NitroNet for Sitecore, although they can still be used in a hybrid scenario.
Disclaimer: This solution has very much been inspired by this blog post by Ben Foster and has been adapted to fit into the Sitecore Helix Architecture.
Introducing the Model Factory
In this approach, we are strictly separating domain models and view models. Latter will mostly be build with or without input parameters.
Based on these specifications, we can introduce an interface as follows:
namespace MyProject.Foundation.Presentation.ModelBuilder
{
public interface IModelFactory
{
TModel CreateModel<TModel>();
TModel CreateModel<TInput, TModel>(TInput input);
}
}
Add the Model Factory to a Foundation Module, as it will be used by most of the Feature Modules and contains no presentation in the form of renderings or views.
The Model Builder
To build complex view models we create two interfaces:
namespace MyProject.Foundation.Presentation.ModelBuilder
{
public interface IModelBuilder<out TModel>
{
TModel Build();
}
public interface IModelBuilder<in TInput, out TModel>
{
TModel Build(TInput input);
}
}
The first interface is for creating view models without input parameters, while the second one is for view models that do have input parameters.
The Model Builder for an example feature could look like this:
namespace MyProject.Feature.Example.ModelBuilder
{
[UsedImplicitly]
public class ExampleElementModelBuilder : AbstractModelBuilder<IExampleElement, ExampleElementViewModel>
{
private readonly IExampleService exampleService;
public ExampleElementModelBuilder(
ISitecoreContext sitecoreContext,
IExampleService exampleService)
: base(sitecoreContext)
{
this.exampleService = exampleService;
}
public override ExampleElementViewModel Build(IExampleElement datasource)
{
return new ExampleElementViewModel
{
// make sure you run the fields from the datasource that should be editable in the experience
// editor thru the rendering pipeline by using <this.GlassHtml.Editable(datasource, d => d.Field)>
ExampleTitle = this.GlassHtml.Editable(datasource, d => d.Title),
ExampleAbstract = this.GlassHtml.Editable(datasource, d => d.Abstract),
ExampleText = this.GlassHtml.Editable(datasource, d => d.Text),
// an injected service can be used as expected
ExampleDateTime = this.exampleService.GetDateTime()
};
}
}
}
While the Model Builder interfaces are located in a Foundation Module, the concrete implementations for the example feature are nicely placed in the Feature Module where they belong.
In perfect harmony with GlassMapper
As you can see in the example above, we use GlassMapper to map Sitecore templates into C# interfaces. Therefore, we extracted some common functionality into a AbstractModelBuilder
base class.
namespace MyProject.Foundation.Presentation.ModelBuilder
{
public abstract class AbstractModelBuilder<TInput, TModel> : IModelBuilder<TInput, TModel>
{
private readonly ISitecoreContext sitecoreContext;
protected AbstractModelBuilder(ISitecoreContext sitecoreContext)
{
this.sitecoreContext = sitecoreContext;
this.GlassHtml = sitecoreContext?.GlassHtml;
}
protected IGlassHtml GlassHtml { get; }
public abstract TModel Build(TInput input);
}
}
The IExampleElement
interface is mapped by GlassMapper
namespace MyProject.Feature.Example.DomainModels
{
[SitecoreType(TemplateId = "{FB9BD3C2-3E47-473B-A02A-E63AC897E69B}")]
public interface IExampleElement : IBaseItem
{
[SitecoreField("Example Title")]
string Title { get; set; }
[SitecoreField("Example Abstract")]
string Abstract { get; set; }
[SitecoreField("Example Text")]
string Text { get; set; }
}
}
The IExampleElement is mapped to an Interface Template as described by the Sitecore Helix Documentation and serialized by Unicorn within the Feature Module.
There's no magic going on in the ExampleElementViewModel
class:
namespace MyProject.Feature.Example.Areas.FeatureExample.Models
{
public class ExampleElementViewModel
{
public string ExampleTitle { get; set; }
public string ExampleAbstract { get; set; }
public string ExampleText { get; set; }
public DateTime ExampleDateTime { get; set; }
}
}
The DefaultModelFactory
The idea behind the model factory is to locate the relevant model builder and use it to build the view model. Additionally, we need a way to resolve dependencies and to benefit from the use of the model factory for simple view models that don't require a builder.
namespace MyProject.Foundation.Presentation.ModelBuilder
{
public class DefaultModelFactory : IModelFactory
{
public TModel CreateModel<TModel>()
{
var builder = ServiceLocator.ServiceProvider.GetService<IModelBuilder<TModel>>();
if (builder != null)
{
return builder.Build();
}
return Activator.CreateInstance<TModel>();
}
public TModel CreateModel<TInput, TModel>(TInput input)
{
var builder = ServiceLocator.ServiceProvider.GetService<IModelBuilder<TInput, TModel>>();
if (builder != null)
{
return builder.Build(input);
}
return (TModel)Activator.CreateInstance(typeof(TModel), input);
}
}
}
In both methods we use the Sitecore ServiceLocator
to try and locate a builder. If one exists we use it to build the view model. If not, we just use reflection to create an instance.
Setting up the Dependency Injection container
The IModelFactory
and your model builders need to be registered in your DI container. Here is an example on how you can do this with Sitecore Dependency Injection and some assembly scanning, so that all model builders will be registered automatically.
namespace MyProject.Foundation.Presentation.DependencyInjection
{
[UsedImplicitly]
public class ServicesConfigurator : ScanningServicesConfigurator
{
public override void Configure(IServiceCollection serviceCollection)
{
var assemblies = GetSolutionAssemblies();
RegisterImplementingTypes(typeof(IModelBuilder<>), serviceCollection, assemblies);
RegisterImplementingTypes(typeof(IModelBuilder<,>), serviceCollection, assemblies);
serviceCollection.AddTransient<IModelFactory, DefaultModelFactory>();
}
}
}
The ServicesConfigurator is located in a Foundation Module and needs to be added to the services node in a Sitecore config file.
namespace MyProject.Foundation.DependencyInjection.DependencyInjection
{
public abstract class ScanningServicesConfigurator : IServicesConfigurator
{
public abstract void Configure(IServiceCollection serviceCollection);
protected static Assembly[] GetSolutionAssemblies()
{
return AppDomain.CurrentDomain.GetAssemblies().Where(a => a.FullName.StartsWith("MyProject")).ToArray();
}
protected static void RegisterImplementingTypes<T>(IServiceCollection serviceCollection, IReadOnlyCollection<Assembly> assemblies)
{
RegisterImplementingTypes(typeof(T), serviceCollection, assemblies);
}
protected static void RegisterImplementingTypes(Type targetType, IServiceCollection serviceCollection, IReadOnlyCollection<Assembly> assemblies)
{
GetTypesImplementing(targetType, assemblies)
.ForEach(type => serviceCollection.AddTransient(type));
GetClosedTypesRegistrations(targetType, assemblies)
.ForEach(requestHandler => serviceCollection.AddTransient(requestHandler.Key, requestHandler.Value));
}
private static List<Type> GetTypesImplementing(Type targetType, IReadOnlyCollection<Assembly> assemblies)
{
if (assemblies == null || assemblies.Count == 0)
{
return new List<Type>();
}
return assemblies
.Where(assembly => !assembly.IsDynamic)
.SelectMany(GetExportedTypes)
.Where(type => !type.IsAbstract && !type.IsGenericTypeDefinition && targetType.IsAssignableFrom(type))
.ToList();
}
private static IDictionary<Type, Type> GetClosedTypesRegistrations(Type targetType, IReadOnlyCollection<Assembly> assemblies)
{
if (assemblies == null || assemblies.Count == 0)
{
return new Dictionary<Type, Type>();
}
return assemblies
.Where(assembly => !assembly.IsDynamic)
.SelectMany(GetExportedTypes)
.Where(type => !type.IsAbstract)
.SelectMany(t => t.GetInterfaces(), (t, i) => new { service = i, type = t })
.Where(r => r.service.IsGenericType && r.service.GetGenericTypeDefinition() == targetType)
.ToDictionary(r => r.service, r => r.type);
}
private static IEnumerable<Type> GetExportedTypes(Assembly assembly)
{
try
{
return assembly.GetExportedTypes();
}
catch (NotSupportedException)
{
// A type load exception would typically happen on an Anonymously Hosted DynamicMethods
// Assembly and it would be safe to skip this exception.
return Type.EmptyTypes;
}
catch (ReflectionTypeLoadException ex)
{
// Return the types that could be loaded. Types can contain null values.
return ex.Types.Where(type => type != null);
}
catch (Exception ex)
{
// Throw a more descriptive message containing the name of the assembly.
throw new InvalidOperationException($"Unable to load types from assembly {assembly.FullName}. {ex.Message}", ex);
}
}
}
}
The result: A clean Sitecore Controller
This has been quite a bit of work, but as a result, your controllers could be as clean as this one:
namespace MyProject.Feature.Example.Areas.FeatureExample.Controllers
{
public class ExampleController : GlassController<IExampleElement>
{
private readonly IModelFactory modelFactory;
public ExampleController(IModelFactory modelFactory)
{
this.modelFactory = modelFactory;
}
public override ActionResult Index()
{
var model = this.modelFactory.CreateModel<IExampleElement, ExampleElementViewModel>(this.DataSource);
return this.View("modules/example/example", model);
}
}
}
The handlebars view could look like this:
<h1>{{{exampleTitle}}}</h1>
{{#if exampleAbstract}}
<p>{{{exampleAbstract}}}</p>
{{/if}}
{{{exampleText}}}
<h2>Non-editable view model values</h2>
<ul>
<li>{{exampleDateTime}}</li>
</ul>
What's next
The example feature shows a rather simple use case. We will have a look at how you can use this pattern with nested model builders to meet all your atomic design needs in a future blog post. Furthermore, I'll show you how EditFrames
and model builders go hand in hand.