I'm always looking for ways to make dev teams more cohesive.

I had a front-end dev, who typically worked on a mac with sublime, and some .net devs - who worked on PC in visual studio.

In the past I might've gone straight to react or angular and built APIs, but with .net core being cross-platform, and new features such as tag helpers that make mountains of confusing (for a front-end developer) razor syntax a thing of the past, it's now much easier to go vanilla.

The front-end developer would work on sublime and use "dotnet watch run" to auto-recompile on git pull, but never actually open a C# source code file. In fact, due to the new controller discovery feature, the entire front-end project would consist only of razor views and some webpack-built (again, using a watcher) front-end resources. They'd be able to create views at will, without controllers, and you'd still be able to do anything you can usually do from a view ( which is also expanded in asp.net core

The .net devs would be able to add controllers and actions at will, to add logic to existing views.

Enter Middleware

Turns out asp.net core ships with the perfect way for us to achieve our goal - Middleware.

Middleware constitutes software components that are daisy-chained together and invoked on each request. MVC itself is just another middleware (actually, MVC is just a route handler)

Let's take a quick look at a simple Startup.cs in asp.net core:

public class Startup
{
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddLogging();
    services.AddMvc();
  }

  public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
  {
    if (env.IsDevelopment())
    {
      loggerFactory.AddConsole(LogLevel.Trace);
      app.UseDeveloperExceptionPage();
    }
    app.UseStaticFiles();
    app.UseMvcWithDefaultRoute();
  }
}

What's going on here?

  1. "ConfigureServices" registers services with the built in IOC container. AddLogging and AddMvc are both extension methods that simply register a bunch of services under the hood. For example AddMvc is registering things such as the view engine, tag helpers, etc. Check out the source code here
  2. "Configure" is setting up the request pipeline. Order matters... This is why logging and developer exception page come first. Logging needs to be available to all middleware below so it's first, and the developer exception page can't catch exceptions unless it's already been invoked by the time the exception occurs. Each middleware takes part in the request and either passes onto the next middleware in the chain, or short-circuits it by handling it on it's own.
  • UseStaticFiles just looks for a static file, if present it serves that file and short-circuits the rest of the chain
  • UseMvcWithDefaultRoute looks for a controller and action that matches the default route, and if found, handles the request.

Each middleware looks something like this:

public class MyMiddleware
{
  private readonly RequestDelegate _next;
  
  // next = the next middleware in the chain (in the order it was added)
  public MyMiddleware(RequestDelegate next)
  {
    _next = next;
  }

  // Request handler
  public async Task Invoke(HttpContext context)
  {
     // do some logic
     
     // optionally handle and short circuit the chain
     if(someCondition)
     {
       await HandleItMyself(context);
       return;
     }
     
     // pass to next middleware
     await _next(context);

     // do some more logic - maybe
  }
}

This gives us total control over the request pipeline... so how does this help us? Well, MVC is just a route handler attached to a Router Middleware. So for us to take over this process, all we need to do is write our own route handler, throw our own logic in, and use that in place of the MVC route handler. Sounds easy? It is!

The MVC Route Handler

The MVC route handler (found here is pretty straight forward - RouteAsync is what we need to concern ourselves with:

public Task RouteAsync(RouteContext context)
{
  if (context == null)
  {
    throw new ArgumentNullException(nameof(context));
  }

  var candidates = _actionSelector.SelectCandidates(context);
  if (candidates == null || candidates.Count == 0)
  {
    _logger.NoActionsMatched();
    return TaskCache.CompletedTask;
  }

  var actionDescriptor = _actionSelector.SelectBestCandidate(context, candidates);
  if (actionDescriptor == null)
  {
    _logger.NoActionsMatched();
    return TaskCache.CompletedTask;
  }

  context.Handler = (c) =>
  {
    var routeData = c.GetRouteData();

    var actionContext = new ActionContext(context.HttpContext, routeData, actionDescriptor);
    if (_actionContextAccessor != null)
    {
      _actionContextAccessor.ActionContext = actionContext;
    }

    var invoker = _actionInvokerFactory.CreateInvoker(actionContext);
    if (invoker == null)
    {
      throw new InvalidOperationException(
        Resources.FormatActionInvokerFactory_CouldNotCreateInvoker(
          actionDescriptor.DisplayName));
    }

    return invoker.InvokeAsync();
  };

  return TaskCache.CompletedTask;
}

As you can see above, all it does is see if there is a matching controller and action, and if found, it executes the matching action. If we were to execute a default controller instead of giving up if we couldn't find a match, we'd achieve our goal. We simply need to change the if (candidates == null || candidates.Count == 0) logic.

Controllerless Route Handler

Create a new class that implements IRouter


public class ControllerlessMvcRouteHandler : IRouter
{
  // everything above RouteAsync is copied from MVC's route handler

  public Task RouteAsync(RouteContext context)
  {
    ...
    var candidates = _actionSelector.SelectCandidates(context);

    // no controller/action available to execute route - run it controllerless
    if (candidates == null || candidates.Count == 0)
    {
      MakeRouteControllerless(context);
      candidates = _actionSelector.SelectCandidates(context); 
    }

    var actionDescriptor = _actionSelector.SelectBestCandidate(context, candidates);
    ...
  }
        
  private void MakeRouteControllerless(RouteContext context)
  {
    var controller = context.RouteData.Values["controller"].ToString();
    var action = context.RouteData.Values["action"].ToString();

    context.RouteData.Values["x-old-controller"] = controller;
    context.RouteData.Values["x-old-action"] = action;
    context.RouteData.Values["controller"] = "default";
    context.RouteData.Values["action"] = "default";
    context.RouteData.Values["viewName"] = $"~/views/{controller}/{action}.cshtml";         
  }
}

Hang on, we just copied the MVC route handler and threw our own logic in? Yep we sure did. If we can't find a matching controller and action, we simply rewrite the route data to some default value and store the old controller and action as new route values in case we need them later. Here is the full version

Builder extensions

We need a couple more classes to finish this off. We need our builder extensions firstly, so we can "use" our middleware. Here it is in all it's glory, note that this is also unashamedly ripped off from the UseMvc builder extensions, found here

    public static class ControllerlessMvcBuilderExtensions
    {
        public static IApplicationBuilder UseControllerlessMvc(this IApplicationBuilder app)
        {
            if (app == null)
            {
                throw new ArgumentNullException(nameof(app));
            }

            return app.UseControllerlessMvc(routes =>
            {
            });
        }

        public static IApplicationBuilder UseControllerlessMvcWithDefaultRoute(this IApplicationBuilder app)
        {
            if (app == null)
            {
                throw new ArgumentNullException(nameof(app));
            }

            return app.UseControllerlessMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}");
            });
        }

        public static IApplicationBuilder UseControllerlessMvc(
            this IApplicationBuilder app,
            Action<IRouteBuilder> configureRoutes)
        {
            if (app == null)
            {
                throw new ArgumentNullException(nameof(app));
            }

            if (configureRoutes == null)
            {
                throw new ArgumentNullException(nameof(configureRoutes));
            }

            // Verify if AddMvc was done before calling UseControllerlessMvc
            // We use the MvcMarkerService to make sure if all the services were added.
            if (app.ApplicationServices.GetService(typeof(MvcMarkerService)) == null)
            {
                throw new InvalidOperationException(string.Format(
                    "Unable to find the required services. Please add all the required services by calling '{0}.{1}' inside the call to '{2}' in the application startup code.",
                    nameof(IServiceCollection),
                    "AddMvc",
                    "ConfigureServices(...)"));
            }

            var routes = new RouteBuilder(app)
            {
                DefaultHandler = new ControllerlessMvcRouteHandler(
                app.ApplicationServices.GetService<IActionInvokerFactory>(),
                app.ApplicationServices.GetService<IActionSelector>(),
                app.ApplicationServices.GetService<DiagnosticSource>(),
                app.ApplicationServices.GetService<ILoggerFactory>()
                )
            };

            configureRoutes(routes);

            routes.Routes.Insert(0, AttributeRouting.CreateAttributeMegaRoute(app.ApplicationServices));

            return app.UseRouter(routes.Build());
        }
    }

Above, the route builder uses the controllerless route handler in-place of the MVC one, other than that it's almost identical.

Default Controller

We'll also want a default controller, designed to take a view name as a parameter. Not much to this one:

    public class DefaultController : Controller
    {
        // render a view
        public IActionResult Default(string viewName)
        {
            return View(viewName);
        }
    }

Wire it all up

And lastly, add our middleware into the chain, in Startup.cs:

public void ConfigureServices(IServiceCollection services)
  {
    ...
    services.AddMvc(); // required for ControllerlessMvc
    ...
  }
  
  public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
  {
    ...
    app.UseControllerlessMvcWithDefaultRoute(); // this replaces UseMvc
    ...  
}

Bam, controllerless views. Also we can package up our default controller with our route handler and builder extensions in a separate assembly because "controller discovery" looks for candidates in the loaded assemblies. Nice and neat.

It all just works... if the controller and action exist, it'll execute it, if they don't, it'll look for a view that matches anyway, and serve that. Unlike rendering a view to a string (which is an alternative method), it will inherit the default behaviour of recompiling and serving the latest view if it's changed on disk, instead of serving a cached version, avoiding the need to compile to get changes.

Thanks to .net core it's easier than ever to have front-end developers mix it up with the .net team.