Implementing resource oriented controllers in ASP.NET MVC
One common problem with the naming convention and default routing mechanism in ASP.NET MVC is that we tend to group actions in a controller for sharing an URL space. This basically leads to complex controllers with a lot of unrelated methods that break the SOLID principles. Too many responsibilities in a simple class affects maintainability in the long run and causes secondary effects that complicates unit testing. For example, we usually end up with class constructors that receives too many dependencies as it is discussed here in SO.
A service façade or a service locator are not the solution for this problem either. I personally think the solution here is to use controllers with fewer responsibilities and define the right routing in case you want to share the URL space. There are some discussions around the idea of using what was called controller-less actions, in which a controller only performs a single thing. Without going to that extreme, we can use a more resource oriented approach for assigning responsibilities to a controller. A resource in http is uniquely identified by an URI and can be manipulated through an unified interface with http verbs. Although some verbs do not make much sense when working with a browser like “delete” or “put”, we can replace those with “post”.
Implementing a delete action with an http get is definitely wrong, as an http get should be idempotent.
Let’s start an example by defining a simple scenario for managing a set of customers. We can initially define two resources, Customers for performing actions in the collection, and Customers/{id} for performing actions in a single customer. Although they both share the same URL space “Customers”, it does not mean we need to implement all the functionality in a single controller called “Customers”. We can still have a “CustomersController” for the collection, and a “CustomerController” for the individual customers. We can use routing for share the same URL and still forward the requests to the right controller.
We can define the following operations in the “CustomersController”,
- Add (GET Customers/Add): Retrieves the Html form representation for creating a new customer
- Add (POST Customers/Add): Receives a representation (encoded in the form) for creating a new customer in the collection
- Delete (POST Customers/{id}/Delete: Removes a customer from the collection
- Index (GET Customers): Retrieves a Html view representing a list of customers
public class CustomersController : Controller
{
ICustomerRepository repository;
public CustomersController()
: this(new CustomerRepository())
{
}
public CustomersController(ICustomerRepository repository)
{
this.repository = repository;
}
public ActionResult Index(string filter = null)
{
IEnumerable<Customer> customers = null;
if (!string.IsNullOrEmpty(filter))
{
customers = this.repository.GetAll()
.Where(c => c.FirstName.Contains(filter) || c.LastName.Contains(filter));
}
else
{
customers = this.repository.GetAll();
}
return View(customers);
}
[HttpGet]
public ActionResult Add()
{
return View();
}
[HttpPost]
public ActionResult Add(Customer customer)
{
if(ModelState.IsValid)
this.repository.Add(customer);
return RedirectToAction("Index");
}
[HttpPost]
public ActionResult Delete(int id)
{
this.repository.Delete(id);
if (Request.IsAjaxRequest())
return new HttpStatusCodeResult((int)HttpStatusCode.OK);
return RedirectToAction("Index");
}
}
The “CustomerController” can have the following operations:
- Get (GET Customers/{id}): Retrieves an Html form representing an specific customer
- Update (POST Customers/{id}): Receives a representation (encoded in the form) for updating the customer
public class CustomerController : Controller
{
ICustomerRepository repository;
public CustomerController()
: this(new CustomerRepository())
{
}
public CustomerController(ICustomerRepository repository)
{
this.repository = repository;
}
[HttpGet]
public ActionResult Get(int id)
{
var customer = this.repository.GetAll()
.Where(c => c.Id == id)
.FirstOrDefault();
if (customer == null)
return new HttpStatusCodeResult((int)HttpStatusCode.NotFound);
return View(customer);
}
[HttpPost]
public ActionResult Update(Customer customer)
{
if (ModelState.IsValid)
this.repository.Update(customer);
return RedirectToAction("Index", "Customers");
}
}
This is how the routing table looks like,
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
//This map is required so the Add segment is not used as {id}
routes.MapRoute(
name: "CustomerAdd",
url: "Customers/Add",
defaults: new { controller = "Customers", action = "Add" }
);
routes.MapRoute(
name: "CustomerGet",
url: "Customers/{id}",
defaults: new { controller = "Customer", action = "Get" },
constraints: new { httpMethod = new HttpMethodConstraint("GET") }
);
routes.MapRoute(
name: "CustomerUpdate",
url: "Customers/{id}",
defaults: new { controller = "Customer", action = "Update" },
constraints: new { httpMethod = new HttpMethodConstraint("POST") }
);
routes.MapRoute(
name: "MVC Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
}
You can override the default route for “Customers/{id}” for getting or updating an specific customer by using an http method constraint. I also added the first route for adding a new customer so the “Add” segment is not used as the “{id}” wildcard.
As you could see, we could split all the responsibilities in two controllers, which look simpler at first glance. In conclusion, you don’t need to assume the same URL means the same controller.
The “delete” action is implemented as an http post. This can be sent from the browser by using an Ajax call or a Http form. For example, the following code shows how to do that using a JQuery Ajax call.
@Html.ActionLink("Delete", "Delete", new { id = item.Id }, new { @class = "delete" })
<script type="text/javascript" language="javascript">1:
2: $(function () {3: $('.delete').click(function () {4: var that = $(this);5: var url = that.attr('href');6:
7: $.post(url, function () {8: alert('delete called');9: });
10:
11: return false;12: });
13: });
</script>