Saturday, April 20, 2013

MVC action filters - viewmodel to domain object mapping (and policies)

I'm undertaking a rather large task to re-implement an existing ASP.NET Web Forms application using ASP.NET MVC. It's been a thoroughly enjoyable piece of work to date, and, in particular, I have a growing fondness for the MVC filter sub system.

Part of the existing app deals with clients applying for a service/facility. The act of applying uses a simple workflow style, most often similar to Start->Details->Confirmation->Receipt. Each of these steps is an ASPX page, and in the new project, an MVC strongly typed view. The state of the client's application must of course be retained as they navigate this simple workflow, being able to proceed forward and backwards as they need.

The MVC implementation employs view models as the strongly typed objects that the Views consume; I have an existing object to object mapping framework (I was forced to write one before tools such as auto mapper existed, and it's been tweaked considerably for my purposes - for example, it does bi directional mapping by default, and can implicitly convert object types where this makes sense).

Additionally, there are some controller wide specific restrictions that should be applied; policies if you will.

I was interested in the approach espoused by Jimmy Bogard here (http://lostechies.com/jimmybogard/2009/06/30/how-we-do-mvc-view-models/). But, IMO, it did not go far enough.

What I have currently is action filters that can apply the controller wide policy (I also have a couple of global filters for site wide policy - something that was done in the ASP.NET web app using http modules).

Additionally, there is a navigation tracking filter, that handles the domain object that is associated with the current client application. On top of that, a mapping filter exists that handles the mapping as required between view model and domain model, updating the domain model held in distributed cache when necessary.

I have 'anonymised' the domain object and view model names, but the intent should be clear from this controller excerpt.

1:  [BasicRestrictionPolicyFilter(RestrictionPolicyType = typeof(TemporalRestriction),   
2:                 Action = "UnavailableFeature",            
3:                 Controller = "Security",   
4:                 RouteName = SharedAreaRegistration.RouteName)]  
5:     [NotifiableRestrictionPolicyFilter(  
6:        RestrictionPolicyType = typeof(EnhancedSecurityRestriction),  
7:        RouteName = SharedAreaRegistration.RouteName,  
8:        MessageType = AlertMessageType.Notice,  
9:        LiteralMessage = "Sorry, you required an enhanced security ability to be active")]  
10:     [NavigationTrackerFilter(DomainObjectType = typeof(SomeApplication))]  
11:     public class ApplyController : BaseController {  
12:        [InjectionConstructor]  
13:        public ApplyController(ISomeService service) {  
14:           SomeService = service;  
15:        }  
16:        [PageFlow]  
17:        [MappingFilter(TargetType = typeof(DetailsViewModel))]  
18:        public ActionResult Start() {  
19:           return View(ViewData.Model);  
20:        }  
21:        [PageFlow(Order = 1)]  
22:        [MappingFilter(TargetType = typeof(DetailsViewModel))]  
23:        public ActionResult Details() {  
24:           return View(ViewData.Model);  
25:        }  
26:        [HttpPost]  
27:        [MappingFilter]   
28:        public ActionResult Details(DetailsViewModel details) {  
29:           if (ModelState.IsValid) return RedirectToAction("Confirmation");  
30:           return View(details);  
31:        }  
32:        [PageFlow(Order = 2)]  
33:        [MappingFilter(TargetType = typeof(SummaryViewModel))]  
34:        public ActionResult Confirmation() {  
35:           return View(ViewData.Model);  
36:        }  
37:        [HttpPost]  
38:        public ActionResult Confirmation(SummaryViewModel summary) {  
39:           SomeService.Order(GetTrackedHostedObject<SomeApplication>());  
40:           return RedirectToAction("Receipt");  
41:        }  
42:        [PageFlow(Order = 3)]  
43:        [MappingFilter(TargetType = typeof(SummaryViewModel))]  
44:        public ActionResult Receipt() {  
45:           return View(ViewData.Model);  
46:        }  
47:        private ISomeService SomeService { get; set; }  
48:     }  

I'll dissect some of this now.
1:  [BasicRestrictionPolicyFilter(RestrictionPolicyType = typeof(TemporalRestriction),   
2:                 Action = "UnavailableFeature",            
3:                 Controller = "Security",   
4:                 RouteName = SharedAreaRegistration.RouteName)]  
5:     [NotifiableRestrictionPolicyFilter(  
6:        RestrictionPolicyType = typeof(EnhancedSecurityRestriction),  
7:        RouteName = SharedAreaRegistration.RouteName,  
8:        MessageType = AlertMessageType.Notice,  
9:        LiteralMessage = "Sorry, you required an enhanced security ability to be active")]  

Explanation:

  • Lines 1-4: Association a controller scope basic policy filter with the Apply controller. This filter instantiates the policy type that is passed to it (TemporalRestriction), asks it if "everything is alright", and if not, redirects the current request to the Security controller, targeting the action "UnavailableFeature"
  • Lines 5-9: Employ a slightly more sophisticated restriction filter, which, on policy failure, redirects the user to a specific 'issues' view that can integrate with our CMS system or use a literal message
This is all rather simple, but the mapping filter behaviour is marginally more complicated.


10:     [NavigationTrackerFilter(DomainObjectType = typeof(SomeApplication))]  
11:     public class ApplyController : BaseController {  
12:        [InjectionConstructor]  
13:        public ApplyController(ISomeService service) {  
14:           SomeService = service;  
15:        }    

Explanation:

  • Line 10: A controller scope navigation tracker filter is declared, that looks after a domain object of type SomeApplication. It's function is really just to ensure that the object exists in the distributed cache we have
  • Lines 11-15: Use Unity to inject a service that is required by the controller

16:        [PageFlow]  
17:        [MappingFilter(TargetType = typeof(DetailsViewModel))]  
18:        public ActionResult Start() {  
19:           return View(ViewData.Model);  
20:        }   

The Start action is the first in the simple workflow we have.

Explanation:

  • Line 16: PageFlow is a simple attribute, not a filter. It is used to support previous/next behaviour. Decorating actions in this fashion allows target actions for the base controller implemented next and previous actions to be inferred automatically. As is seen later in the controller, you can specify an 'order' property, to note the sequence in the workflow where an action 'resides'
  • Line 17: Request that the current domain model (managed by the navigation tracker filter) be mapped into a view model of type DetailsViewModel.
Things get more interesting when types can be inferred, as below:
26:        [HttpPost]  
27:        [MappingFilter]   
28:        public ActionResult Details(DetailsViewModel details) {  
29:           if (ModelState.IsValid) return RedirectToAction("Confirmation");  
30:           return View(details);  
31:        }    

Explanation:

  • Line 26: Note that this is a POST request
  • Line 27: Request mapping of the DetailsViewModel object to the existing Domain object - we know both these types, so no specification of them is necessary in the mapping filter declaration 

37:        [HttpPost]  
38:        public ActionResult Confirmation(SummaryViewModel summary) {  
39:           SomeService.Order(GetTrackedHostedObject<SomeApplication>());  
40:           return RedirectToAction("Receipt");  
41:        }   

Explanation:

  • Line 37: This is the client confirming that they wish to proceed
  • Line 39: Use our Unity injected service to place an order, supplying the domain object we have been tracking and updating.
Again, most of this is quite straightforward. But the mapping filter is performing a number of actions behind the scenes, including:

GET requests
Request that the tracked domain object be mapped into the view model, and set the controllers model to the newly created and populated view model. All this occurs in the OnActionExecuted(...) override (well, not literally, as the mapping filter behaviour is split across a number of classes).

POST requests
Two distinct possibilities here:
  • OnActionExecuting(...):  if the filter has been told to examine the model state, and it is valid, use the mapping service to map the view model into the domain object, and update the distributed cache (with the modified domain object).
  • OnResultExecuting(...): if the model state is invalid, and we have to been told to care about that, ask the mapping service to execute all the 'pre-maps' of the view model, as the act of posting it back will have not done that. If a view model defines pre-maps (think of these as initialization actions), ask the mapping service to execute them on behalf of the view model. This means that the view model will then be in a self consistent state. 
This is a 'toe in the water' implementation at the moment, but it seems to have promise.

1 comment:

Sam Stephens said...

This approach looks like it has a lot of promise. I love how declarative it all is, how little code there is, how much is inferred (for example MappingFilter with no parameters). I'd be interested how this works as you use it in more scenarios, if you find edge cases easy to incorporate.