Monday, November 8, 2010

The Journey to thinner controllers - MVC QueryActionResult

In the endless drive to smaller and smaller controllers, I'm gunna see if I can't make an ActionResult for query results. Basically, while this code doesn't suck at all.
   1:  public ActionResult Details(Guid id)
   2:          {
   3:              var details = _detailsReadModelRepository.GetById(id);
   4:              return View(details);
   5:          }



I think we can make it even better. I really wanna call out that a method is a Query. So I'm gunna make a QueryActionResult. So I just create the class and inherit ActionResult. It has one abstract on it, ExecuteResults(ControllerContext). Lets start really simple first. I'm thinking we just take in a Func<Object> that will execute the query to get the viewmodel, then send that off to the the rendering engine.

   1:  using System;
   2:  using System.Web.Mvc;
   3:   
   4:  namespace myDojo.Infrastructure.Web
   5:  {
   6:      public class QueryActionResult : ViewResult
   7:      {
   8:          public Func<object> Query { get; set; }
   9:          
  10:          public override void ExecuteResult(ControllerContext context)
  11:          {
  12:              QueryMustBeSet();
  13:              ViewData.Model = Query();
  14:              base.ExecuteResult(context);
  15:          }
  16:   
  17:          private void QueryMustBeSet()
  18:          {
  19:              if(Query == null)
  20:                  throw new ArgumentNullException("Query");
  21:          }
  22:      }
  23:  }

Ok, simple enough, but we really didn't accomplish much. The consumer code now looks like this.



   1:  public ActionResult Details(Guid id)
   2:          {
   3:              return new QueryActionResult
   4:                         {
   5:                             Query = () => _detailsReadModelRepository.GetById(id)
   6:                         };
   7:          }

Yeah, that's probably worse than when we started. To be fair though, the only reason View(object) is convenient is because of the convenience method on Controller that sets the ViewData.Model. So, since I'm all about being fair, I'll go add a convenience method to our base controller.

   1:    public QueryActionResult Query(Func<object> query)
   2:          {
   3:              return new QueryActionResult
   4:                         {
   5:                             Query = query
   6:                         };
   7:          }

Now the consumer looks like this.

   1:  public QueryActionResult Details(Guid id)
   2:          {
   3:              return Query(() => _detailsReadModelRepository.GetById(id));
   4:          }

I like that. Pretty nice huh? Of course we can add overloads to the Query method that'll let you send to different Views (just like View() has on Controller).

But I think I can make it a little better. I think I want it to look like this.

   1:   public ActionResult Details(Guid id)
   2:          {
   3:              return Query(new GetDetailsForMartialArtist(id));
   4:          }

To do that, I'm gunna create a little Query interface. Essentially, I'll just be using the command pattern. Here's the interface.

   1:  namespace myDojo.Infrastructure.CQRS.Query
   2:  {
   3:      public interface IQuery
   4:      {
   5:          object Execute();
   6:      }
   7:  }

Next, I just go make a Query that gets details by id. Uh oh! Wait... dependencies. I have to specify the params for the query somewhere, and I don't want to constructor inject them, because that just smells (even though that's what I just wrote - that happens to me a lot.). I'd rather just write the query like this.

   1:   
   2:   
   3:  using System;
   4:  using myDojo.Infrastructure;
   5:  using myDojo.Infrastructure.CQRS.Query;
   6:  using MyDojo.Query.ViewModels;
   7:   
   8:  namespace MyDojo.Query.Queries
   9:  {
  10:      public class MartialArtistDetailsById : IQuery
  11:      {
  12:          private readonly IReadModelRepository<MartialArtistDetails> _repository;
  13:   
  14:          public MartialArtistDetailsById(IReadModelRepository<MartialArtistDetails> repository)
  15:          {
  16:              _repository = repository;
  17:          }
  18:   
  19:          public Guid Id { get; set; }
  20:   
  21:          public MartialArtistDetails Execute()
  22:          {
  23:              IdMustBeSet();
  24:              return _repository.GetById(Id);
  25:          }
  26:   
  27:          private void IdMustBeSet()
  28:          {
  29:              if(Id.Equals(Guid.Empty))
  30:                  throw new ArgumentException("Id","Id must be set");
  31:          }
  32:      }
  33:  }

This looks pretty good, but how to make it work??? I really don't want to have to new that Query object up, because that means I'll have to DI every dependency for every query on a controller. That just seems counterproductive to me. Let's see what we can do.

Instead of injecting Func<object>, we'll take an Action<TQuery> that we'll use to build up the query properties so that the params for the queries are properly set. Like so.

   1:  using System;
   2:  using System.Web.Mvc;
   3:  using myDojo.Infrastructure.CQRS.Query;
   4:   
   5:  namespace myDojo.Infrastructure.Web
   6:  {
   7:      public class QueryActionResult<TQuery> : ViewResult where TQuery:IQuery
   8:      {
   9:          public Action<TQuery> BuildUpQuery { get; set; }
  10:          public override void ExecuteResult(ControllerContext context)
  11:          {
  12:              var query = ServiceLocation.CurrentContainer.GetInstance<TQuery>();
  13:              BuildUpQuery(query);
  14:              ViewData.Model = query.Execute();
  15:              base.ExecuteResult(context);
  16:          }
  17:      }
  18:  }

So we change the convenience method to look like this.

   1:   public QueryActionResult<TQuery> Query<TQuery>(Action<TQuery> buildUpQuery) where TQuery: IQuery
   2:          {
   3:              return new QueryActionResult<TQuery>
   4:                         {
   5:                            BuildUpQuery = buildUpQuery,
   6:                         };
   7:          }

Now the controller looks like this.

   1:   public QueryActionResult<MartialArtistDetailsById> Details(Guid id)
   2:          {
   3:              return Query<MartialArtistDetailsById>(q => q.Id = id);
   4:          }
 
Ok... let's use some optional parameters to make this more usable.
 

   1:  public QueryActionResult<TQuery> Query<TQuery>(Action<TQuery> buildUpQuery=null,string viewName=null) where TQuery: IQuery
   2:          {
   3:              var result = new QueryActionResult<TQuery>
   4:                         {
   5:                            BuildUpQuery = buildUpQuery,
   6:                            
   7:                         };
   8:              if (!String.IsNullOrEmpty(viewName))
   9:                  result.ViewName = viewName;
  10:              return result;
  11:          }
 
Don't want a null ref for BuildUpQuery in QueryActionResult, so let's just not execute it if it's null.

   1:  using System;
   2:  using System.Web.Mvc;
   3:  using myDojo.Infrastructure.CQRS.Query;
   4:   
   5:  namespace myDojo.Infrastructure.Web
   6:  {
   7:      public class QueryActionResult<TQuery> : ViewResult where TQuery:IQuery
   8:      {
   9:          public Action<TQuery> BuildUpQuery { get; set; }
  10:          public override void ExecuteResult(ControllerContext context)
  11:          {
  12:              var query = ServiceLocation.CurrentContainer.GetInstance<TQuery>();
  13:              if(BuildUpQuery!=null)
  14:                  BuildUpQuery(query);
  15:              ViewData.Model = query.Execute();
  16:              base.ExecuteResult(context);
  17:          }
  18:      }
  19:  }

Let's go fix our List() method on the UserController now.

Before:

   1:  public ActionResult List()
   2:          {
   3:              var users = _detailsReadModelRepository.GetAll().AsEnumerable();
   4:              return View(users);
   5:          }

After:

   1:  public ActionResult List()
   2:          {
   3:              return Query<AllMatialArtists>();
   4:          }

I like it. Do you?

Code is up on github.

3 comments:

  1. Or you could just do this:

    public ActionResult Details(Details details)
    {
    return View(details);
    }

    And then just construct a model binder to handle the GetById -> great thing about this pattern is if you use IRepository & IFilteredModelBinder, you can construct a model binder that is capable of retrieving any Entity by Id automagically. See this blog post for the IFilteredModelBinder:
    http://www.lostechies.com/blogs/jimmy_bogard/archive/2009/11/19/a-better-model-binder-addendum.aspx

    For the rest you just setup an IFilteredModelBinder with the following properties:
    1. A where forcing T to derive from Entity (base nhibernate model)
    2. A ctor that takes IRepository
    3. Implement the IsMatch interface to return true if type derives from entity.
    4. inside BindModel method, just ask the value provider (probably querystring) for the id parameter.
    5. return repository.GetById(id);

    The class looks something like this (guard checks etc removed):
    public class EntityBinder : IFilteredModelBinder where T : Entity
    {
    private IRepository _repository;

    public EntityBinder(IRepository repository)
    {
    _repository = repository;
    }
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
    var id = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
    return _repository.GetById(id);
    }

    public bool IsMatch(Type type)
    {
    return type.DerivesFrom();
    }
    }

    ReplyDelete
  2. Thanks Anon...
    I'll take a look at it!

    ReplyDelete
  3. Play The Real Money Slot Machines - Trick-Taking Game - Trick-Taking
    How to https://tricktactoe.com/ Play. Play The Real https://febcasino.com/review/merit-casino/ Money Slot 출장마사지 Machine. If you herzamanindir.com/ are searching for หาเงินออนไลน์ a fun, exciting game to play online, we have you covered.

    ReplyDelete