adapting
ASP.NET Core MVC
to your needs

Filip W

a random guy from the internet

strathweb.com @filip_woj github.com/filipw

Setup

Setup

  • 🕊 No MVC
    ASP.NET Core apps built on routing only
  • 🎯 ActionResults without MVC
    ActionResults in middleware
  • 👯 MVC side-by-side
    Multiple sub-applications in the same app


For lightweight, small Web APIs (microservices 🤧), you may not use MVC at all and instead rely only on ASP.NET Core Routing.

ASP.NET Core 2.1 / 2.2 - IRouter

    
public static async Task Main(string[] args) =>
   await WebHost.CreateDefaultBuilder(args)
      .ConfigureServices(s => s.AddRouting())
      .Configure(app =>
      {
          app.UseRouter(r => // define all API endpoints
          {
              r.MapGet("contacts", async (req, res, data) =>
              { });

              r.MapGet("contacts/{id:int}", async (req, res, data) =>
              { });
          });
      }).Build().RunAsync();
    

ASP.NET Core 3.0 - Endpoint Routing

    
public static async Task Main(string[] args) =>
   await WebHost.CreateDefaultBuilder(args)
      .ConfigureServices(s => s.AddRouting())
      .Configure(app =>
      {
            app.UseRouting(r =>
            {
                r.MapGet("/contacts", async context =>
                { });

                r.MapGet("contacts/{id:int}", async context =>
                { }).RequireAuthorization("API");
            });
      }).Build().RunAsync();
    

ActionResult in a controller

                        
[HttpGet("contacts")]
public IActionResult Get()
{
    var contacts = new[]
    {
        new Contact { Name = "Filip", City = "Zurich" },
        new Contact { Name = "Not Filip", City = "Not Zurich" }
    };

    // will do content negotation
    return new ObjectResult(contacts);
}
                                

ASP.NET Core 2.1 introduced IActionResultExecutor<T> which allows us to use IActionResults outside of controllers.

Like in a controller

    
r.MapGet("contacts", async (request, response, routeData) =>
{
    var contacts = new[]
    {
        new Contact { Name = "Filip", City = "Zurich" },
        new Contact { Name = "Not Filip", City = "Not Zurich" }
    };

    var result = new ObjectResult(contacts);
    await response.WriteActionResult(result);
});
    

You can run multiple isolated ASP.NET Core pipelines, with their own DI containers, in the same process (parent application).

    
public class CustomerStartup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddTransient<IGreetService, HiService>();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async c => 
        {
            var svc = c.RequestServices.GetRequiredService<IGreetService>();
            await c.Response.WriteAsync(svc.Greet());
        });
    }
}
    


public class Startup
{
    public void ConfigureServices(IServiceCollection services) 
    { }

    public void Configure(IApplicationBuilder app)
    {
        app.IsolatedMap<CustomerStartup>("/customer");
        app.IsolatedMap<AdminStartup>("/admin");
    }
}

Plugins and Extensibility

Plugins and Extensibility

  • 📋 IFileProvider
    Virtual file system
  • 📦 ApplicationPartManager
    Externally loaded features
  • 🏗 Change providers
    Rebuild application model at runtime
  • 🎁 Packaging Razor
    Ship MVC plugins with their own embedded UI

It is possible to use virtual file systems in MVC apps by implementing an IFileProvider.

Multiple providers can be used together via a CompositeFileProvider.

Custom file provider

    
var blobFileProvider = app.ApplicationServices.GetRequiredService<AzureBlobFileProvider>();

app.UseStaticFiles(new StaticFileOptions()
    {
        FileProvider = blobFileProvider,
        RequestPath = "/files"
    };
    

ApplicationPartManager is used to load ASP.NET Core MVC features.

Adding controllers from external assembly

    
services.AddMvc().ConfigureApplicationPartManager(a =>
{
    var binDirectory = fileProvider.GetDirectoryContents("plugins");
    foreach (var item in binDirectory)
    {
        using (var assemblyStream = item.CreateReadStream())
        {
            using (MemoryStream ms = new MemoryStream())
            {
                assemblyStream.CopyTo(ms);
                var assembly = Assembly.Load(ms.ToArray());
                a.ApplicationParts.Add(new AssemblyPart(assembly));
            }
        }
    }
});
    

IActionDescriptorChangeProvider can be used to rebuild the MVC action descriptor cache.

This allows building plugin systems, where controllers and actions are added and removed dynamically, without restarting the application

Adding an assembly to a running MVC app

    
var assembly = Assembly.Load(assemblyPath);
var partFactory = ApplicationPartFactory.GetApplicationPartFactory(assembly);
foreach (var part in partFactory.GetApplicationParts(assembly))
{
    applicationPartManager.ApplicationParts.Add(part);
}
actionDescriptorChangeProvider.TokenSource.Cancel();
    

.NET Core 3.0 adds support for collectible assemblies, which is perfect for plugins.

With Microsoft.NET.Sdk.Razor it is possible to precompile Razor views and distribute them in a DLL.

MVC Application Model

Application Model

  • 🔧 Understanding the model
    MVC component snapshot
  • 📚 Customizing the model
    IApplicationModelConvention
  • ⛔️ IActionConstraint
    Customize the action selection


Application Model

  • ✅ Controllers
    Defined behaviours
  • ✅ Actions & Parameters
    Select relevant methods, defined behaviours
  • ✅ Filters
    Apply filters
  • ✅ Selectors
    Mapping to routing

At application startup the default MVC behaviors can be changed by modifying the application model.

    
[ApiController]
[Route("[controller]")]
public class BookController : Controller 
{
    [HttpPost]
    public IActionResult PostBook([FromBody]Book book) 
    { 
        if (ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        // controller logic
    }
}
    

    
public class AuthorizeConvention : IApplicationModelConvention
{
    public void Apply(ApplicationModel application)
    {
        foreach (var controller in application.Controllers)
        {
            foreach (var actionModel in controller.Actions)
            {
                if (actionModel.ActionName.StartsWith("Authorized") && 
                    !actionModel.Filters.OfType<IAuthorizeData>().Any()) 
                {
                    actionModel.Filters.Add(new AuthorizeFilter());
                }
            }
        }
    }
}
    

IApplicationModelProvider should be used by frameworks and libraries, while IApplicationModelConvention by applications.
Providers always run before conventions.

IActionConstraint is used to impose additional action matching rules.

    
public class OrderController
{
    [Route("order")]
    public IActionResult Process(Order order)
    {
        // handle VIP customers
        // handle standard customers
        // handle basic customers
    }
}
    

    
public class OrderController
{
    [VIPOnly]
    [Route("order")]
    public IActionResult ProcessVIP(Order order) { }

    [StandardOnly]
    [Route("order")]
    public IActionResult ProcessStandard(Order order) { }

    [BasicOnly]
    [Route("order")]
    public IActionResult ProcessBasic(Order order) { }
}
    

IActionConstraint may disambiguate multiple actions from matching the same request or reject a certain request from being handled by an action candidate.

    
public class SwissAcceptLanguageActionConstraint : IActionConstraint, IActionConstraintMetadata
{
    public int Order => 0;

    public bool Accept(ActionConstraintContext ctx)
    {
        var headers = ctx.RouteContext.HttpContext.Request.GetTypedHeaders();
        
        return headers.AcceptLanguage != null && 
            headers.AcceptLanguage.Any(x => x.Value.Equals("de-CH", StringComparison.OrdinalIgnoreCase)))
    }
}
    

Thank you