This blog is a step by step tutorial that transforms a default MVC 5 application into Jimmy Bogard's Feature oriented configuration as described in his presentation. His sample project located here was used to guide the way. The VSIX package and source code of this presentation are available here.
Background
Layers to Features
From:
To:
What is a feature
A feature is a small vertical slice of a web site. The Model, View and Controller of a single url. It can contain the following:
- Query
- QueryValidater
- QueryHandler
- Result
- Command
- CommandValidater
- CommandHandler
- UiController
- MappingProfile
- View
A Feature handles the get and or post of an http requests for a single url.
Examples: http://localhost/User/Index, http://localhost/User/Create
It can be synchronous or asynchronous, and can have a redirect to url/Feature upon command success. Features use the CQRS light design pattern.
CQRS Light
The CQRS (Command Query Responsibility Segregation) pattern means many different thing to different people. The full blown CQRS with dual domain models and event sourcing etc. gets a bit overwhelming rather quickly. "CQRS Light" is just the splitting of Features into Queries and Commands. A single domain model is the starting point and will not be split unless circumstances drive the need, which is unlikely. There is no event sourcing and most messaging is done in process using MediatR.
The Http protocol correlates rather well with CQRS Light.
Get = Read = Query
Post = Write = Command
The MediatR Nuget package will be used to implement CQRS light.
This should all become clear as we implement. Let's get started.
Tutorial
Create Default MVC 5 Application
Create New Project named JimmyMvc
Choose MVC and Press Change Authentication and select "No Authentication"
Deselect Host in the cloud
Select Ok to create the project.
Run the app to confirm it works as is.
Select Tools->NuGet Package Manager->Package Manager Console and enter:
Update-Package
Alternatively Select Tools->NuGet Package Manager->Manage NuGet Packages for Solution
Upgrade all to the latest stable version.
Run the app to confirm it still works.
Configure JustCode/ReSharper/CodeRush
These tools are not required but they sure are handy. Take some time to configure them and they will help make your code cleaner and more consistent. This will not be covered here.
Rename Views to Features
Rename the Views folder to Features
So now if you run the app you will get an error:
12345678910
The view 'Index' or its master was not found or no view engine supports the searched locations. The following locations were searched:
~/Views/Home/Index.aspx
~/Views/Home/Index.ascx
~/Views/Shared/Index.aspx
~/Views/Shared/Index.ascx
~/Views/Home/Index.cshtml
~/Views/Home/Index.vbhtml
~/Views/Shared/Index.cshtml
~/Views/Shared/Index.vbhtml
To fix this we change the view engine to one that looks in the proper location:
Create a new root folder named Infrastructure
Inside the Infrastructure folder Create new class named: FeatureRazorViewEngine
To use the new ViewEngine, update the Global.asax.cs as follows:
12345678910111213141516171819202122
namespaceJimmyMvc{usingInfrastructure;usingSystem.Web.Mvc;usingSystem.Web.Optimization;usingSystem.Web.Routing;publicclassMvcApplication:System.Web.HttpApplication{protectedvoidApplication_Start(){AreaRegistration.RegisterAllAreas();FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);RouteConfig.RegisterRoutes(RouteTable.Routes);BundleConfig.RegisterBundles(BundleTable.Bundles);// Replace Default ViewEngine with Feature versionViewEngines.Engines.Clear();ViewEngines.Engines.Add(newFeatureRazorViewEngine());}}}
Running the application now should give you :
The layout page "~/Views/Shared/_Layout.cshtml" could not be found at the following path: "~/Views/Shared/_Layout.cshtml".
To fix this, Update the _ViewStart.cshtml to point to the Feature based site Layout location.
123
@{Layout="~/Features/Shared/_Layout.cshtml";}
You application will now function using the "Feature" folder instead of the "View" folder.
Removing the Controllers Folder
Move HomeController.cs to the Features\Home directory and delete the Controllers folder.
Using the feature concept we no longer have to indicate the purpose of the controller by calling it <Home>Controller. Given it is in the Home namespace the prefix becomes redundant.
Rename HomeController.cs to UiController.cs and the class name to UiController
Update the namespace inside the file.
1
namespaceJimmyMvc.Features.Home
Running the application will now give an error:
The resource cannot be found.
The url format is set in the RouteConfig as follows: (Notice this is the default and is not changed.)
MVC controllers are created using the DefaultControllerFactory.CreateController and it uses a naming convention of <controllerName>Controller to get the controller type. This will no longer work with our new feature based naming convention.
To fix this create your own controller factory class in the Infrastructure Folder as follows:
Later after we add an IoC container this will need to be removed.
At this point the Global.asax.cs file should be as follows:
12345678910111213141516171819202122232425
namespaceJimmyMvc{usingInfrastructure;usingSystem.Web.Mvc;usingSystem.Web.Optimization;usingSystem.Web.Routing;publicclassMvcApplication:System.Web.HttpApplication{protectedvoidApplication_Start(){AreaRegistration.RegisterAllAreas();FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);RouteConfig.RegisterRoutes(RouteTable.Routes);BundleConfig.RegisterBundles(BundleTable.Bundles);// Replace Default ViewEngine with Feature versionViewEngines.Engines.Clear();ViewEngines.Engines.Add(newFeatureRazorViewEngine());//Replace Default Controller factor with Feature versionControllerBuilder.Current.SetControllerFactory(newJimmyMvc.Infrastructure.ControllerFactory());}}}
The Application should now run. But what is the purpose of this application?
The Business Case
A Company wants to automate their purchase order (PO) approval process. To reduce clutter and wasted time, they want to establish a hierarchy for approval. People in the first group must approve before requesting action from the second. All of the people in the list must approve the PO in order for the funds to be allocated and the PO be issued as approved.
Typically the team that is closest to the project, needs to approve first. If all of them approve then the next group will be notified and requested to approve or reject. This second group is often a set of managers involved in a project. Finally the next group, potentially senior executives are involved. Lastly one person must have the financial signing authority to release the funds.
This blog only covers the implementation of "Approval List Templates" which can be reused when the user actually wants to request approval of a purchase order. The process of requesting approval of the purchase order is another business component and is not covered in this post.
Sample "Approval List Template" names are ... "Minimal Approval", "Medium Approval", "The Big Dogs"
Class Diagram
Granted this model is pretty simple in that the behavior is just CRUD. The use of the ApprovalListTemplate will have more domain behavior and possibly could be the topic of a future blog.
Use Case
Let's get started with our first use case.
As an administrator we need to be able to maintain a set of users for the application.
TODO Use case Diagram here
Being able to persist users will require a database. For this case we will use Entity Framework on top of SQL Server.
Add Entity Framework 6
Create a root folder named Entities and select it.
Press Ctrl-Shift-A To add a new item.
Select Visual C#->Data-> ADO.NET Entity Data Model And Name it "Model"
Select Empty Code First model and then Finish
This will add the Entity Framework Nuget package to your project and Create a Model.cs file and update your Web.config
Make sure the Entity Framework Nuget is up to date using Nuget Package Manager.
namespaceJimmyMvc.Entities{usingSystem;usingSystem.Data.Entity;usingSystem.Data.Entity.ModelConfiguration.Conventions;usingSystem.Linq;publicclassModel:DbContext{// Your context has been configured to use a 'Model' connection string from your application's // configuration file (App.config or Web.config). By default, this connection string targets the // 'JimmyMvc.Model' database on your LocalDb instance. // // If you wish to target a different database and/or database provider, modify the 'Model' // connection string in the application configuration file.publicModel():base(nameOrConnectionString:"name=Model"){}// Add a DbSet for each entity type that you want to include in your model. For more information // on configuring and using a Code First model, see http://go.microsoft.com/fwlink/?LinkId=390109.// public virtual DbSet<MyEntity> MyEntities { get; set; }publicvirtualDbSet<User>Users{get;set;}protectedoverridevoidOnModelCreating(DbModelBuilderaDbModelBuilder){aDbModelBuilder.Conventions.Remove<PluralizingTableNameConvention>();}}}
Create Database Initializer
Add a new class named ModelInitializer to the Entities Folder as follows:
Tell Entity Framework to use your initializer class by adding the following to the Global.asax.cs file as shown and the proper using statments:
12
// Set Database InitializerDatabase.SetInitializer<Model>(newModelInitializer());
The intializer will execute on first use. If you want the initializer to execute immediately add the following code in the Global.asax.cs following the above.
namespaceJimmyMvc{usingInfrastructure;usingEntities;usingSystem.Web.Mvc;usingSystem.Web.Optimization;usingSystem.Web.Routing;usingSystem.Data.Entity;publicclassMvcApplication:System.Web.HttpApplication{protectedvoidApplication_Start(){AreaRegistration.RegisterAllAreas();FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);RouteConfig.RegisterRoutes(RouteTable.Routes);BundleConfig.RegisterBundles(BundleTable.Bundles);// Replace Default ViewEngine with Feature versionViewEngines.Engines.Clear();ViewEngines.Engines.Add(newFeatureRazorViewEngine());//Replace Default Controller factor with Feature versionControllerBuilder.Current.SetControllerFactory(newJimmyMvc.Infrastructure.ControllerFactory());// Set Database InitializerDatabase.SetInitializer<Model>(newModelInitializer());using(varmodel=newModel()){model.Database.Initialize(force:false);}}}}
Execute the application now. And the database should be created and populated with data.
Lets get started implementing our first Feature and building the infrastructure needed to support it.
Feature 1: Display the list of users ##
The url for the Feature will be /User/Index
We will store all of the Features in the Features folder organized by the Entity/Aggregate root of which they are associated. So add a User folder under the Features folder.
Add a class named Index to the User folder. This will contain the Features logic.
Add an Index view by right clicking on the User folder and selecting Add->MVC 5 View Page (Razor)
Nest the Index.cshtml file under the Index.cs file to keep the solution organized, using the File Nesting add-in.
Replace the default index.cshtml code with the following temporary code which is just used so we can see something when the view renders. We will update this later.
123
<div>ListofUsers</div>
Add a link to the main menu in the Features\Shared\_Layout.cshtml to the Users page.
Given that each feature now represents a single url the name of the controller method no longer needs to correspond to the url as there can only be one method for get and one for post on each controller. So we will name all of our methods "Action" and thus give us the opportunity to be more DRY. A controller class now can represent a single url. We will use this knowledge later when we create a LinkBuilder class.
Execute the app and attempt to navigate to the Users page.
You will get The resource cannot be found. error. This is because the controller can not be found using our Controller Factory. The current factory is looking for a controller of type "JimmyMvc.Features.User.UiController" and an method named "Index".
Our controllers fully qualified name is "JimmyMvc.Features.User.Index+UiController" because we have embedded it inside the feature. So we either have to change the factory, or not embed the controller. Having done this both ways I chose to embed the controller as this allows for a more DRY solution. Also the method we are looking for is always named Action we will address this also in the controller factory.
Executing the application and navigating to the home url (Home/Index) will now give "The resource cannot be found." but when you manually enter /User/Index you should see the view page.
So let's fix the Home/About, Home/Contact, Home/Index Features
under Features\Home Create classes named About, Contact and Index.
Take the existing controller and split it up among the respective classes.
now delete the Home\UiController.cs file and nest all the cshtml files under there respective .cs files.
At this point, the site should function and all the pages should work as well. This gives one a basic idea of the concept but much more is needed for your typical business application.
The User/Index Feature needs to display the list of users vs the current temporary page.
Add Nuget packages
First we need to add the nuget packages we are going to use in our features.
This adds 4 dependent packages and also adds the files displayed below.
Now run Update-Package to update to the latest version of the dependent packages.
Build and run now will give the following error:
An instance of IControllerFactory was found in the resolver as well as a custom registered provider in ControllerBuilder.GetControllerFactory. Please set only one or the other.
Now that we have a container we no longer need the explicit setting of the controller factory in Global.asax.cs file
Note that a nuget called WebActivatorEx was a dependency and was also installed. "WebActivator is a NuGet package that allows other packages to easily bring in Startup and Shutdown code into a web application. This gives a much cleaner solution than having to modify global.asax with the startup logic from many packages"
Thus the StructuremapMVC.cs file has a Start method that will execute when the site starts up.
// --------------------------------------------------------------------------------------------------------------------// <copyright file="DefaultRegistry.cs" company="Web Advanced">// Copyright 2012 Web Advanced (www.webadvanced.com)// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// http://www.apache.org/licenses/LICENSE-2.0// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License.// </copyright>// --------------------------------------------------------------------------------------------------------------------namespaceJimmyMvc.DependencyResolution{usingEntities;usingFluentValidation;usingInfrastructure;usingStructureMap.Configuration.DSL;usingStructureMap.Graph;usingStructureMap.Pipeline;usingSystem.Web.Mvc;//using JimmyMvc.Infrastructure.Validation;usingMehdime.Entity;publicclassDefaultRegistry:Registry{#region Constructors and DestructorspublicDefaultRegistry(){Scan(aAssemblyScanner=>{aAssemblyScanner.TheCallingAssembly();aAssemblyScanner.WithDefaultConventions();aAssemblyScanner.LookForRegistries();aAssemblyScanner.AssemblyContainingType<DefaultRegistry>();aAssemblyScanner.AddAllTypesOf(typeof(IModelBinder));aAssemblyScanner.AddAllTypesOf(typeof(IModelBinderProvider));aAssemblyScanner.With(newControllerConvention());});For<IControllerFactory>().Use<ControllerFactory>();For<ModelValidatorProvider>().Use<FluentValidationModelValidatorProvider>();For<IDbContextFactory>().Use<DbContextFactory>().LifecycleIs<ContainerLifecycle>();For<IDbContextScopeFactory>().Use<DbContextScopeFactory>().LifecycleIs<ContainerLifecycle>();For<IAmbientDbContextLocator>().Use<AmbientDbContextLocator>().LifecycleIs<ContainerLifecycle>();//For<IValidatorFactory>().Use<StructureMapValidatorFactory>();}#endregion}}
This will automatically look for Registries that we will create as we proceed. We will use the Validation logic later.
Update UiController.Action to now take the Query as a parameter. The controller is now a very simple implementation of taking the Query and sending it to the QueryHandler and sending the result to the view. The mediatR nuget package is made exactly to fill this need and is injected into the controller.
publicclassQueryHandler:IRequestHandler<Query,Result>{privateIDbContextScopeFactoryDbContextScopeFactory{get;}publicQueryHandler(IDbContextScopeFactoryaDbContextScopeFactory){DbContextScopeFactory=aDbContextScopeFactory;}publicResultHandle(QueryaQuery){varresult=newResult{CurrentSort=aQuery.SortOrder,NameSortParm=String.IsNullOrEmpty(aQuery.SortOrder)?"name_desc":"",};if(aQuery.SearchString!=null){aQuery.Page=1;}else{aQuery.SearchString=aQuery.CurrentFilter;}result.CurrentFilter=aQuery.SearchString;result.SearchString=aQuery.SearchString;using(vardbContextScope=DbContextScopeFactory.CreateReadOnly()){Modelmodel=dbContextScope.DbContexts.Get<Model>();IQueryable<User>users=fromsinmodel.Usersselects;if(!String.IsNullOrEmpty(aQuery.SearchString)){users=users.Where(aUser=>aUser.LastName.Contains(aQuery.SearchString)||aUser.FirstAndMiddleName.Contains(aQuery.SearchString));}switch(aQuery.SortOrder){case"name_desc":users=users.OrderByDescending(aUser=>aUser.LastName);break;default:// Name ascending users=users.OrderBy(aUser=>aUser.LastName);break;}intpageSize=3;intpageNumber=(aQuery.Page??1);result.Users=users.ProjectToPagedList<Result.User>(pageNumber,pageSize);returnresult;}}}
MappingProfile
Automapper can be used to map between Domain entities and our Queries, Results and Commands. This mapping needs to be defined.
Please add a nested MappingProfile class inside the Index class as follows:
We have commented out our future links since the Create,Edit,Details,Delete Features are yet to be completed.
Build and run and now you should be able to see the Users list with paging. All features currently implemented should now be functional.
Feature User/Details
Now we have most of the foundation we need to start cranking out some features. The details feature is the most similar to the Index feature in that we have a Query, QueryHandler, Result, UiController, Mapping and a View. So will go there next, although this time we are going to use an asynchronous process.
Create the Details class in the User Directory
User.Details
We have a trivial Query object in that we only need the UserId. The result of the query will be the User information and a list of owned ApprovalListTemplates.
@usingJimmyMvc.Features.User@modelDetails.Result@{ViewBag.Title="Details";}<h2>Details</h2><div><h4>User</h4><hr/><dlclass="dl-horizontal"><dt>@Html.DisplayLabel(aModel=>aModel.LastName)</dt><dd>@Html.Display(aModel=>aModel.LastName)</dd><dt>@Html.DisplayLabel(aModel=>aModel.FirstAndMiddleName)</dt><dd>@Html.Display(aModel=>aModel.FirstAndMiddleName)</dd><dt>@Html.DisplayLabel(aModel=>aModel.ApprovalListTemplates)</dt><dd><tableclass="table"><tr><th>Name</th><th>RecipientCount</th></tr>@for(vari=0;i<Model.ApprovalListTemplates.Count;i++){<tr><td>@Html.Display(aResult=>aResult.ApprovalListTemplates[i].Name)</td><td>@Html.Display(aResult=>aResult.ApprovalListTemplates[i].RecipientCount)</td></tr>}</table></dd></dl></div><p>@*@(Html.FeatureLink<Edit.UiController>(aLinkText:"Edit",aRouteValues:newEdit.Query{UserId=Model.UserId}))|*@@(Html.FeatureLink<Index.UiController>(aLinkText:"Back to List"))</p>
Excute and run.
Notice we commented out the Edit link above as we have yet to implment that feature. I would like to do that now. But first we must introduce Validation.
Validation
Typically Validation is done on each controller doing something like
The solution we are going to implement is documented here
Create a Validation folder under the Infrastructure Folder.
ValidationActionFilter
Using an existing MVC Extension point create an ActionFilter that intercepts the action if the ModelState is NOT valid it will return a json serialized version of the ModelState.
varhighlightFields=function(response){$('.form-group').removeClass('has-error');$.each(response,function(propName,val){varnameSelector='[name = "'+propName.replace(/(:|\.|\[|\])/g,"\\$1")+'"]',idSelector='#'+propName.replace(/(:|\.|\[|\])/g,"\\$1");var$el=$(nameSelector)||$(idSelector);if(val.Errors.length>0){$el.closest('.form-group').addClass('has-error');}});};varhighlightErrors=function(xhr){try{vardata=JSON.parse(xhr.responseText);highlightFields(data);showSummary(data);window.scrollTo(0,0);}catch(e){// (Hopefully) caught by the generic error handler in `config.js`.}};varshowSummary=function(response){$('#validationSummary').empty().removeClass('hidden');varverboseErrors=_.flatten(_.pluck(response,'Errors')),errors=[];varnonNullErrors=_.reject(verboseErrors,function(error){returnerror.ErrorMessage.indexOf('must not be empty')>-1;});_.each(nonNullErrors,function(error){errors.push(error.ErrorMessage);});if(nonNullErrors.length!==verboseErrors.length){errors.push('The highlighted fields are required to submit this form.');}var$ul=$('#validationSummary').append('<ul></ul>');_.each(errors,function(error){var$li=$('<li></li>').text(error);$li.appendTo($ul);});};varredirect=function(data){if(data.redirect){window.location=data.redirect;}else{window.scrollTo(0,0);window.location.reload();}};$('form[method=post]').not('.no-ajax').on('submit',function(){varsubmitBtn=$(this).find('[type="submit"]');submitBtn.prop('disabled',true);$(window).unbind();var$this=$(this),formData=$this.serialize();$this.find('div').removeClass('has-error');$.ajax({url:$this.attr('action'),type:'post',data:formData,contentType:'application/x-www-form-urlencoded; charset=UTF-8',dataType:'json',statusCode:{200:redirect},complete:function(){submitBtn.prop('disabled',false);}}).error(highlightErrors);returnfalse;});
namespaceJimmyMvc.DependencyResolution{usingEntities;usingFluentValidation;usingInfrastructure;usingStructureMap.Configuration.DSL;usingStructureMap.Graph;usingStructureMap.Pipeline;usingSystem.Web.Mvc;usingInfrastructure.Validation;usingMehdime.Entity;publicclassDefaultRegistry:Registry{#region Constructors and DestructorspublicDefaultRegistry(){Scan(aAssemblyScanner=>{aAssemblyScanner.TheCallingAssembly();aAssemblyScanner.WithDefaultConventions();aAssemblyScanner.LookForRegistries();aAssemblyScanner.AssemblyContainingType<DefaultRegistry>();aAssemblyScanner.AddAllTypesOf(typeof(IModelBinder));aAssemblyScanner.AddAllTypesOf(typeof(IModelBinderProvider));aAssemblyScanner.With(newControllerConvention());});For<ModelValidatorProvider>().Use<FluentValidationModelValidatorProvider>();For<IControllerFactory>().Use<ControllerFactory>();For<IDbContextFactory>().Use<DbContextFactory>().LifecycleIs<ContainerLifecycle>();For<IDbContextScopeFactory>().Use<DbContextScopeFactory>().LifecycleIs<ContainerLifecycle>();For<IAmbientDbContextLocator>().Use<AmbientDbContextLocator>().LifecycleIs<ContainerLifecycle>();For<IValidatorFactory>().Use<StructureMapValidatorFactory>();}#endregion}}
ControllerExtensions
Create class ControllerExtensions in the Infrastructure/Extensions Folder as follows:
@usingJimmyMvc.Features.User@modelEdit.Command@{ViewBag.Title="Edit";}<h2>Edit</h2>@using(Html.BeginForm()){@Html.AntiForgeryToken()<divclass="form-horizontal"><h4>User</h4><hr/>@Html.ValidationDiv()@Html.Input(aCommand=>aCommand.UserId)@Html.FormBlock(aCommand=>aCommand.LastName)@Html.FormBlock(aCommand=>aCommand.FirstAndMiddleName)<divclass="form-group"><divclass="col-md-offset-2 col-md-10"><inputtype="submit"value="Save"class="btn btn-default"/></div></div></div>}<div>@(Html.FeatureLink<Index.UiController>(aLinkText:"Back to List"))</div>@sectionScripts{@Scripts.Render(paths:"~/bundles/jqueryval")}
Index.cshtml
Update the Index.cshtml page to now link to the Edit page
We are going to implement this synchronously.
Create the Create.cs class in the User folder.
User.Create.Query
Create the nested Query class in the Create class as follows:
This really is an empty class which means simply that their is no information needed but the request will return a Command.
1
publicclassQuery:IRequest<Command>{}
User.Create.QueryHandler
This just creates a new command object and returns it.
@usingJimmyMvc.Features.User@modelCreate.Command@{ViewBag.Title="Create";}<h2>Create</h2>@using(Html.BeginForm()){@Html.AntiForgeryToken()<divclass="form-horizontal"><h4>User</h4><hr/>@Html.ValidationDiv()@Html.FormBlock(aCommand=>aCommand.LastName)@Html.FormBlock(aCommand=>aCommand.FirstAndMiddleName)<divclass="form-group"><divclass="col-md-offset-2 col-md-10"><inputtype="submit"value="Create"class="btn btn-default"/></div></div></div>}<div>@(Html.FeatureLink<Index.UiController>(aLinkText:"Back to List"))</div>@sectionScripts{@Scripts.Render(paths:"~/bundles/jqueryval")}
User\Index.cshtml
In the Index.cshtml file update the create link to the following
@usingJimmyMvc.Features.User@modelDelete.Command@{ViewBag.Title="Delete";}<h2>Delete</h2><pclass="error">@ViewBag.ErrorMessage</p><h3>Areyousureyouwanttodeletethis?</h3><div><h4>User</h4><hr/><dlclass="dl-horizontal"><dt>@Html.DisplayLabel(aModel=>aModel.LastName)</dt><dd>@Html.Display(aModel=>aModel.LastName)</dd><dt>@Html.Display(aModel=>aModel.FirstAndMiddleName)</dt><dd>@Html.DisplayFor(aModel=>aModel.FirstAndMiddleName)</dd></dl>@using(Html.BeginForm()){@Html.AntiForgeryToken()@Html.Input(aModel=>aModel.UserId)<divclass="form-actions no-color"><inputtype="submit"value="Delete"class="btn btn-default"/>|@(Html.FeatureLink<Index.UiController>(aLinkText:"Back to List"))</div>}</div>
Index.cshtml
Update the Delete link to the following:
@(Html.FeatureLink<Delete.UiController>(aLinkText: "Delete", aRouteValues: new Delete.Query { UserId = item.UserId }))
Build, run test.
Mediated Controllers
Create a folder under Infrastructure named MediatedControllers
MediatedController
Reviewing the UiControllers we notice many of them start with
Now we could change all the controllers that require a mediator to inherit from MediatedController instead of Controller. But hold off as we can refactor these even more DRYly.
MediatedGetController
Notice that all of the mediated controllers get operations send the Query to the mediator and then send the result to the View.
either Sync example
Looking at the controllers that handle the post we notice that they also handle the get as above. So we can inherit that portion. The post Action takes the command which is the result of the query and sends it out via the mediator and then returns a RedirectToActionJson which normally is to an index.
Again they can do it sync or async.
namespaceJimmyMvc.Infrastructure.MediatedControllers{usingSystem.Threading.Tasks;usingSystem.Web.Mvc;usingMediatR;usingExtensions;publicclassMediatedAsyncGetPostController<TQuery,TCommand>:MediatedAsyncGetController<TQuery,TCommand>whereTQuery:IAsyncRequest<TCommand>whereTCommand:IAsyncRequest{publicMediatedAsyncGetPostController(IMediatoraMediator):base(aMediator){}publicvirtualstringActionName{get{return"Index";}}// Default redirects to the IndexpublicvirtualstringControllerName{get{return"";}}// Default is blank and thus the same controller that got here [HttpPost] [ValidateAntiForgeryToken]publicvirtualasyncTask<ActionResult>Action(TCommandaCommand){awaitMediator.SendAsync(aCommand);returnthis.RedirectToActionJson(ActionName,ControllerName);}}}
Refactor Controllers
Now we can go back to each UiController and have them inherit from the appropriate type and thereby reduce the amount of code. Notice if we do not want this default handler we can simply inherit from Controller.
Notice that the majority of the Handlers use a DbContext. A single dbcontext per Handler method would normally suffice. Although there are times that you may want to do parallel processing or even have a separate DbContext. So I want to make the normal single dbcontext easy without limiting options in case we want more control.
Also notice that for Queries we want a read only DbContext and for Commands we want a writeable context.
To do this we will create specialized default QueryHandlers and CommandHandlers and again support both sync and async.
namespaceJimmyMvc.Infrastructure.RequestHandlers{usingMediatR;usingMehdime.Entity;usingSystem;usingSystem.Data.Entity;publicclassDbContextQueryHandler<TQuery,TResponse,TDbContext>:IRequestHandler<TQuery,TResponse>whereTQuery:IRequest<TResponse>whereTResponse:IRequestwhereTDbContext:DbContext{protectedIDbContextScopeFactoryDbContextScopeFactory{get;}publicDbContextQueryHandler(IDbContextScopeFactoryaDbContextScopeFactory){DbContextScopeFactory=aDbContextScopeFactory;}publicTResponseHandle(TQueryaQuery){using(vardbContextScope=DbContextScopeFactory.CreateReadOnly()){TDbContextdbContext=dbContextScope.DbContexts.Get<TDbContext>();returnHandleInScope(aQuery,dbContext);}}protectedvirtualTResponseHandleInScope(TQueryaCommand,TDbContextaDbContext){thrownewNotImplementedException(message:"Override this method in your Handler");returndefault(TResponse);}}}
publicclassQueryHandler:DbContextQueryHandler<Query,Result,Model>{publicQueryHandler(IDbContextScopeFactoryaDbContextScopeFactory):base(aDbContextScopeFactory){}protectedoverrideResultHandleInScope(QueryaQuery,ModelaModel){varresult=newResult{CurrentSort=aQuery.SortOrder,NameSortParm=String.IsNullOrEmpty(aQuery.SortOrder)?"name_desc":"",};if(aQuery.SearchString!=null){aQuery.Page=1;}else{aQuery.SearchString=aQuery.CurrentFilter;}result.CurrentFilter=aQuery.SearchString;result.SearchString=aQuery.SearchString;IQueryable<User>users=fromsinaModel.Usersselects;if(!String.IsNullOrEmpty(aQuery.SearchString)){users=users.Where(aUser=>aUser.LastName.Contains(aQuery.SearchString)||aUser.FirstAndMiddleName.Contains(aQuery.SearchString));}switch(aQuery.SortOrder){case"name_desc":users=users.OrderByDescending(aUser=>aUser.LastName);break;default:// Name ascending users=users.OrderBy(aUser=>aUser.LastName);break;}intpageSize=3;intpageNumber=(aQuery.Page??1);result.Users=users.ProjectToPagedList<Result.User>(pageNumber,pageSize);returnresult;}}
So now I think you should understand the concept. You can now review the final code on github.