Tales from the .NET Migration Trenches - Session State

Posts in this series:

Believe it or not, things have been relatively simple so far. In the next few posts, we'll get to the more interesting/complicated bits. First up is session state. If your app doesn't use session state - congratulations! You're one of the lucky few. But most reasonably sized applications I see these days make some use of session state, as a way to provide a per-user cache of data.

The good news is that ASP.NET Core supports, and has supported, session state for a very long time. This means it's a robust feature that you can rely on working solidly for you in your new application. The bad news is there is zero backwards compatibility between ASP.NET Core and ASP.NET. This means we have a couple options to us:

  • Migrate away from ASP.NET Session state or create something custom...somehow
  • Get the two applications to play nice...somehow

The first option might be a possibility if your application doesn't use Session that much or can do without, at least until post-migration. But for most systems I run into, Session was added for Very Good reasons and it's not easily taken away.

While ASP.NET Core isn't backwards compatible, luckily for us option 2 is available to us with the Microsoft.AspNetCore.SystemWebAdapters library.

Incremental Session State Migration Options

While ASP.NET Core has session state, it's not directly backwards compatible for a number of reasons, first and foremost that the implementation is completely different. The spirit of session state was carried forward, but none of the "bad stuff". ASP.NET Session did a few things "automatically" for you, including locking and serialization. With ASP.NET Core, there's no locking and no default serialization. You're given a key and byte[], but there are libraries and extensions to make serialization easier.

The adapters library gives us two options to migrate session state:

  • Remote app
  • Wrapped ASP.NET Core

Remote app is quite clever and we'll see this technique used in the future. The ASP.NET application exposes API endpoints for session state. The ASP.NET Core application can then call these API endpoints to get/set session state:

With this approach, the ASP.NET Core application doesn't care how the ASP.NET application stores its session state. In a web farm scenario, it's common to store session state in SQL Server. ASP.NET Core also supports this storage, but of course it's a completely different schema/format etc.

The other option is a "wrapped" session state in ASP.NET Core, which makes session available to the System.Web adapters (and therefore easier to migrate at the end).

Configuring Remote Session State

With remote session state, we first need to expose our API endpoints in the ASP.NET application:

SystemWebAdapterConfiguration.AddSystemWebAdapters(this)
    // Provide a strong API key that will be used to authenticate the request on the remote app for querying the session
    // ApiKey is a string representing a GUID
    .AddRemoteAppServer(options => options.ApiKey = ConfigurationManager.AppSettings["RemoteAppApiKey"])
    .AddSessionServer();

The "RemoteAppApiKey" is a string (typically a GUID) that represents a shared secret between the ASP.NET Core client and ASP.NET server. When the ASP.NET Core client calls any APIs (such as session), this API is included as a header to authenticate those calls.

Next we need to find out what usages of Session we have in ASP.NET to share over with ASP.NET Core, because each of those keys and serialization mechanisms need to be registered. Looking in my sample application, I just have the one:

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

    Session["FavoriteInstructor"] = $"{command.FirstMidName} {command.LastName}";

    return this.RedirectToActionJson("Index");
}

Yes it's silly but I'm also lazy. The key part here is understanding what keys and values are being used. Old in-memory session state from ASP.NET was...let's say generous? About what was stored. In the new world, we have to explicitly serialize. So why not JSON? Back in our ASP.NET Global.asax, let's register this key and serializer:

this.AddSystemWebAdapters()
    .AddJsonSessionSerializer(options =>
    {
        options.RegisterKey<string>("FavoriteInstructor");
    })
    .AddRemoteAppServer(options => 
        options.ApiKey = ConfigurationManager.AppSettings["RemoteAppApiKey"])
    .AddSessionServer();

Your application may require changes in the objects you keep in session to be able to expose them via an API.

Next, we'll need to register our adapters and client on the ASP.NET Core side:

builder.Services.AddSystemWebAdapters()
    .AddJsonSessionSerializer(options =>
    {
        options.RegisterKey<string>("FavoriteInstructor");
    })
    .AddRemoteAppClient(options =>
    {
        options.RemoteAppUrl = new(builder.Configuration["ProxyTo"]);

        options.ApiKey = builder.Configuration["RemoteAppApiKey"];
    })
    .AddSessionClient();

Remember, all keys and objects must be explicitly registered. This may seem like a hassle, but our ASP.NET Core server must include code changes to read/write from these remote session items so it's not that much of a problem in practice.

Finally, we need to make our remote session available to inject into our controllers:

app.MapDefaultControllerRoute()
    .RequireSystemWebAdapterSession();

To use the session state, the reads and writes are as normal on our .NET Framework ASP.NET application:

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

    Session["FavoriteInstructor"] = $"{command.FirstMidName} {command.LastName}";

    return this.RedirectToActionJson("Index");
}

But on the ASP.NET Core side, we need to go through the System.Web adapters:

public ActionResult Index()
{
    ViewBag.FavoriteInstructor = 
        System.Web.HttpContext.Current?.Session?["FavoriteInstructor"]
        ?? string.Empty;

    return View();
}

Technically we can inject the ISessionManager object into our controller but its definition is a little wonky to use in practice. So if you need to unit test/mock Session, you'd want that interface.

When we include the Session on every request, then every request will call back to the ASP.NET application to get its entire session and store it locally (and manage updates). When using the Session attribute on controllers, this session is only requested for that single controller. Updates are managed via middleware, POSTing the session data from the ASP.NET Core application back to the ASP.NET application.

At some point, we will need to migrate all of the session state over to the ASP.NET Core application, but we'll cover this at the end with the "turning off the lights" step.

In the next post, we'll look at another common "shared state" problem - background tasks with Hangfire.