Migrating Contoso University Example to Razor Pages

A coworker noticed that it looked like Razor Pages were the new "recommended" way of building server-side rendered web applications in ASP.NET Core 2.0. I hadn't paid much attention because at first glance they looked like Web Forms.

However, that's not the case. I forked my Contoso University example (how I like to build MVC applications) and updated it to use Razor Pages instead. Razor Pages are similar to a controller-less action, and fit very well into the "feature folder" style we use on our projects here at Headspring. You can check out Steve Smith's MSDN Magazine article for more.

Back to our example, let's look first at our typical MVC application. We use:

  • AutoMapper
  • MediatR
  • HtmlTags
  • FluentValidation
  • Feature Folders

And it winds up looking something like:

We're able to move the controllers into the feature folder, but the controllers themselves are rather pointless. They're there basically to satisfy routing.

Years ago folks looked at building controller-less actions, but I'm hesitant to adopt divergence from the fundamental building blocks of the framework I'm on. Extend, configure, but not abandon.

So I left those controllers there. The files next to the views contain:

  • View Models
  • MediatR request/responses
  • MediatR handlers
  • Validators

Most of our applications don't actually use inner classes, but adjacent classes (instead of Foo.Query in Foo.cs, FooQuery.cs. But everything is together.

My initial skepticism with Razor Pages came from it looking like the examples having everything shoved in the View. That, combined with a deep distaste for the abomination that is Web Forms.

With that, I wanted to understand what our typical architecture looked like with Razor Pages.

Migrating to Razor Pages

Migrating is fairly straightforward, it was basically renaming the Features folder to Pages, and renaming my vertical slice files to have a cshtml.cs extension:

Next I needed to make my vertical slice class inherit from PageModel:

public class Create : PageModel

This class will now handle the GET/POST requests instead of my controller. I needed to move my original controller actions (very complicated):

public async Task<IActionResult> Edit(Edit.Query query)
{
    var model = await _mediator.Send(query);

    return View(model);
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(Edit.Command command)
{
    await _mediator.Send(command);

    return this.RedirectToActionJson(nameof(Index));
}

Over to the Razor Pages equivalent (On<Method>Async):

public class Edit : PageModel
{
    private readonly IMediator _mediator;

    [BindProperty]
    public Command Data { get; set; }

    public Edit(IMediator mediator) => _mediator = mediator;

    public async Task OnGetAsync(Query query) => Data = await _mediator.Send(query);

    public async Task<IActionResult> OnPostAsync()
    {
        await _mediator.Send(Data);

        return this.RedirectToPageJson(nameof(Index));
    }

The thing I needed to figure out was that model binding and view models are now just properties on my PageModel. I settled on a convention of having a property named Data rather than making up some property name for every page. This also kept with my convention of having only one model used in my views.

Links with Razor Pages are a little different, so I had to go through and replace my tags from asp-controller to asp-page. Not terrible, and I could incrementally move one controller at a time.

Finally, I moved the AutoMapper configuration inside this class too. With all this in place, my Razor Page includes:

  • Page request methods
  • MediatR Request/response models (view models)
  • View rendering
  • Validators
  • Mapping configuration
  • MediatR handlers

I'm not sure how I could make things more cohesive at this point. I will note that standard refactoring techniques still apply - if logic gets complicated in my command handlers, this should be pushed to the domain model to handle.

The final weird thing was around my views. The model for a "Razor Page" is the PageModel class, not my original ViewModel. This meant all my views broke. I needed to change all my extensions and tag helpers to include the Data. prefix on my markup, from:

<form asp-action="Edit">
    @Html.ValidationDiv()
    <input-tag for="Id"/>

    <div class="form-group">
        <label-tag for="Id"/>
        <div><display-tag for="Id"/></div>
    </div>
    
    @Html.FormBlock(m => m.Title)
    @Html.FormBlock(m => m.Credits)
    @Html.FormBlock(m => m.Department)

To:

<form method="post">
    @Html.ValidationDiv()
    <input-tag for="Data.Id" />

    <div class="form-group">
        <label-tag for="Data.Id" />
        <div><display-tag for="Data.Id" /></div>
    </div>

    @Html.FormBlock(m => m.Data.Title)
    @Html.FormBlock(m => m.Data.Credits)
    @Html.FormBlock(m => m.Data.Department)

This messed up my rendering, because our intelligent tag helpers use the property navigation to output text. I had to inform our tag helpers to NOT display the "Data" property name (our tag helpers automatically display text for Foo.Bar.Baz or FooBarBaz to "Foo Bar Baz").

So for property chains that start with Data., I remove that text from our labels:

Labels
    .Always
    .ModifyWith(er => er.CurrentTag.Text(er.CurrentTag.Text().Replace("Data ", "")));

This could be made more intelligent, only looking for property chains that have an initial "Data" property. But for my sample this is sufficient.

I don't test controllers, nor would I test the action methods on my PageModel class. My tests only deal with MediatR requests/responses, so none of my tests needed to change. I could get rid of MediatR in a lot of cases, and make the methods just do the work on my PageModel, but I really like the consistency and simplicity of MediatR's model of "one-model-in, one-model-out".

All in all, I like the direction here. Those building with vertical slice architectures will find a very natural fit with Razor Pages. It's still up to you how much you use nested classes, but this truly makes everything related to a request (minus domain model) all part of a single modifiable location, highly cohesive.

Nice!