Introducing Route Generator for .NET

Introducing Route Generator for .NET

How to avoid navigation hell by having Routes auto-generated for you

ยท

11 min read

Introduction

Modern .NET applications have adopted the concept of routes for navigation. Usually, these routes are string-based, which has a couple of implications. One of the problems is the volatility of route names, which I am addressing in this blog post.

A while ago, when working on a .NET MAUI application with some colleagues, I realized that I could simplify our job and at the same time gain more stability and confidence in our string-based route navigation by having route identifiers automatically generated for us. This is how the idea for the Route Generator was born, which I am going to introduce here today.

Although this post and the sample project focus on .NET MAUI, the concepts and the Route Generator can be applied to different contexts and UI frameworks.

First, we'll have a look at which problems the Route Generator is supposed to solve and then we'll see how simple it is to use the Route Generator in your own project.

If you're interested in seeing the generator in action or want to have a look at the implementation, it's open source and the repository including the sample project can be found on GitHub.

Problems

Since routes are often string-based, route names are volatile by nature. For example, if a route is called "SomePage", but later gets renamed to "XyzPage", then this may create a problem, because it potentially breaks the navigation when the new name isn't used in routing calls. Often, developers resort to using nameof(SomePage) to circumvent this issue. Therefore, instead of registering a route using a verbatim string

Routing.RegisterRoute("SomePage", typeof(SomePage));

we can often see something like this:

Routing.RegisterRoute(nameof(SomePage), typeof(SomePage));

where SomePage is a class that represents some type of page that can be navigated to.

This is usually done, because it avoids issues when SomePage gets renamed or deleted. Changes are instantly noticeable, because the project doesn't compile anymore if an unknown symbol is used in the nameof(SomePage) and typeof(SomePage) calls. However, since renaming operations usually would also update the symbol usages, changing the name of a page shouldn't actually produce any immediate problems. And if for some reason it does, then the compiler lets us know that something is broken and we can go ahead and fix it.

All this is not an issue when it comes to route registration. However, it very much becomes a problem when performing string-based route navigation, especially when applying the MVVM pattern. If we want to perform navigation in the ViewModel (or even in the code-behind), the calling code must either have knowledge of the type and call nameof(SomePage) or, again, use a verbatim string instead, i.e. "SomePage":

// Approach #1. Problem: caller depends on SomePage type
await Shell.Current.GoToAsync($"/{nameof(SomePage)}");

// Approach #2. Problem: verbatim route is volatile
await Shell.Current.GoToAsync("/SomePage");

In the first approach, we would introduce a dependency on the SomePage class for the caller. This usually is less of an issue when performing navigation exclusively from within the code-behind of a XAML-based UI application (depending on the app architecture). It definitely is a violation of the MVVM pattern, though, when doing this inside of a ViewModel and potentially makes the ViewModel untestable (with regards to unit tests), because the ViewModel would have a dependency on the View.

The second approach uses a verbatim string instead of using nameof(SomePage). This seems fine at first glance, but introduces another issue: Routes aren't reliable (volatile). Imagine you have an app with tens or even hundreds of potential routes and you use verbatim strings for route navigation, then you may quickly end up with hours of debugging and looking for the culprit, when the route name has changed from "SomePage" to "XyzPage". The following code would still compile, but navigation will certainly fail:

// compiles, but the route "SomePage" won't be found during runtime
// if it has previously been renamed to "XyzPage"
await Shell.Current.GoToAsync("/SomePage");

Typically, frameworks like .NET MAUI will emit an error informing the developer that a route couldn't be found. Developers sometimes also wrap navigation statements in try-catch-blocks to catch possible navigation exceptions, which only prevents the app from crashing, but it doesn't solve the navigation problem.

Suboptimal workarounds

We can attempt to work around this, e.g. by adding a static class that contains all route names by referencing the page classes inside of it:

namespace MyApp;

public static class Routes
{
    public const string SomePage = nameof(SomeFeature.View.SomePage);
    public const string AnotherPage = nameof(AnotherFeature.View.AnotherPage);
}

This seems to work even with the MVVM pattern, but note that we actually introduce a transitive dependency by using nameof(SomePage), etc. in the static class.

While nameof() actually produces a compile-time constant, there is an indirect dependency on the page classes during compilation. Therefore, we technically end up with a violation of the MVVM pattern, after all. The dependency on SomePage may be "gone" from the ViewModel and may not prevent unit tests anymore, but now it just has become implicit instead of explicit, which is even worse from a dependency management perspective, because it's hidden.

If we do this, we could at least rename the SomePage class and our navigation would still work. This still is problematic, though, because essentially, if we rename the SomePage class to XyzPage and don't update the Routes constants, we would end up with a Routes class looking like this:

namespace MyApp;

public static class Routes
{
    public const string SomePage = nameof(SomeFeature.View.XyzPage);
    public const string AnotherPage = nameof(AnotherFeature.View.AnotherPage);
}

This is awkward, because we would still use the Routes.SomePage identifier for navigation, but it actually navigates to XyzPage - the route name doesn't match with the identifier anymore, which may lead to a lot of confusion:

// will navigate to XyzPage now...
await Shell.Current.GoToAsync($"/{Routes.SomePage}");

This isn't pretty or advisable, because it's utterly inconsistent.

Now, another tempting approach would then be to just use constant strings directly instead of using nameof(SomePage) in order to remove the transitive dependencies:

public static class Routes
{
    public const string SomePage = "SomePage";
    public const string AnotherPage = "AnotherPage";
}

While this works, the route identifiers would be again be highly volatile, because renaming the SomePage class wouldn't be reflected in the static Routes class unless a search-and-replace operation is performed afterwards in order to also rename the symbol and update the constant string value.

Personally, I prefer that a project fails to compile when something changed over noticing changes only during runtime. If a breaking change (renamings usually are breaking changes) is introduced, I would like to know and be able to react to it directly instead of waiting for a bug to be found or an exception to occur during runtime.

So, how can we use constant route identifiers while avoiding invalid routes then in order to prevent ending up in navigation hell?

Enter Route Generator

Enter Route Generator. It's an incremental source generator that produces a static Routes class like above, always based on the currently available pages in a project. Now, in order to only generate routes for classes that actually represent pages, the generator takes an argument to specify a naming convention for routes.

"How is this different from manually typing a static Routes class?" you might ask. Well, that's a great question and the answer is simple:

Any changes are immediately reflected in the Routes class when a page gets added, removed or renamed. Renaming a page class will automatically lead to compiler errors in places where a constant route identifier was used before, because it won't exist anymore. This doesn't happen when you manually implement such a class.

For example, when renaming the SomePage class to XyzPage, the following

public static class Routes
{
    public const string SomePage = "SomePage";
    public const string AnotherPage = "AnotherPage";
}

will become

public static class Routes
{
    public const string XyzPage = "XyzPage";
    public const string AnotherPage = "AnotherPage";
}

This is perfect, because if we previously used

// will fail to compile, because Routes.SomePage doesn't exist anymore
// after renaming it to XyzPage
await Shell.Current.GoToAsync($"/{Routes.SomePage}");

we now will have to update the code to fix the resulting compiler error:

await Shell.Current.GoToAsync($"/{Routes.XyzPage}");

Let's look at how you can add the Route Generator and use it in your project next.

How to use Route Generator

The Route Generator, which implements the IIncrementalGenerator interface, is still in preview at the time of writing and can be downloaded and installed from nuget.org. Just add the epj.RouteGenerator package to your project (you may need to enable the "Include prerelease" check box in the Nuget Package Manager):

Then, in order to have routes generated, all you need to do is use the [AutoRoutes("SomeSuffix")] attribute from the epj.RouteGenerator namespace anywhere in the target project where your pages are defined.

While the attribute can be added to any class within the target project, I highly recommend using it to decorate your MauiProgram class, if you use .NET MAUI:

using epj.RouteGenerator;

namespace RouteGeneratorSample;

// use this attribute to have Routes generated using the provided suffix
[AutoRoutes("Page")]
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        return builder.Build();
    }
}

Note that you can only use a single instance of the [AutoRoutes("Suffix")] attribute. When the Route Generator finds this attribute, it uses the provided suffix as a naming convention and looks for any classes whose names end in this suffix.

A common way to name pages is to use the "Page" suffix like above, but you can use any other naming convention, as well. The generator does not provide a default, so you will always have to specify your suffix with the attribute. Once added, route identifiers are automatically generated.

The generated static Routes class will be located in the same root namespace of the class that was decorated with it:

// <auto-generated/>
namespace RouteGeneratorSample
{
    public static class Routes
    {
        public const string SomePage = "SomePage";
    }
}

While you can also use the identifiers of the auto-generated Routes class to register routes (usually done in AppShell.xaml.cs when using .NET MAUI), this is not mandatory or necessary:

// both ways to register routes work and are equally valid
Routing.RegisterRoute(nameof(SomePage), typeof(SomePage));
Routing.RegisterRoute(Routes.AnotherPage, typeof(AnotherPage));

The important thing is that you can now use the generated identifiers for navigation without having to worry about volatile route names anymore:

await Shell.Current.GoToAsync($"/{Routes.SomePage}");

This works, because as soon as you rename a class, the route identifiers are regenerated.

Extra Routes

Now, you might encounter situations where routes do not follow the specified naming convention. To cover for this scenario, the Route Generator actually provides a second attribute that allows you to specify extra route names manually.

Imagine you have a page that is simply called Dashboard and thus doesn't follow the naming convention using the "Page" suffix of the remaining pages, then you can specify this route identifier explicitly:

// generate routes based on "Page" suffix
// and also add "Dashboard" as an extra route explicitly
[AutoRoutes("Page")]
[ExtraRoute("Dashboard")]
public static class MauiProgram
{
    // ...
}

Now, the Dashboard identifier will also be included in the generated routes:

public static class Routes
{
    public const string SomePage = "SomePage";
    public const string AnotherPage = "AnotherPage";
    public const string Dashboard = "Dashboard";
}

Although this is the same as manually typing the identifiers and strings, the Route Generator makes sure that the Routes are always up-to-update and you'll see compiler errors whenever a page gets renamed or removed.

A quick note about using Shell.Current

If you're using .NET MAUI or Xamarin.Forms, then you may be inclined to directly access Shell.Current to perform any string-based route navigation:

await Shell.Current.GoToAsync($"/{Routes.SomePage}");

While this is acceptable when performing navigation exclusively in the code-behind, I recommend hiding any calls to the different overloads of the GoToAsync() method behind an interface and inject that into your ViewModel. This will remove the Shell.Current dependency and your ViewModel remains MVVM-compliant and unit-testable, because you'll be able to mock the navigation:

private readonly INavigationService _navigationService;

public MyViewModel(INavigationService navigationService)
{
    _navigationService = navigationService;
}

public async Task OpenSomePageAsync()
{
    await _navigationService.GoToAsync($"/{Routes.SomePage}");
}

You can find an example of this INavigationService and its implementation in the sample project of the Route Generator repository.

Conclusions and next steps

In my quest for maintainable and less boilerplate code, I have come across source generators a while ago and this blog post is my latest addition during this journey. This time I have written about the first source generator that I have developed and published myself. (It turns out that writing source generators is pretty straightforward when you know what you need.)

String-based route navigation can be a finicky thing and changing route names and adjusting navigation calls can be quite tedious. If approached naively with verbatim strings, it can also be highly error-prone, which is why I have developed the Route Generator. I have shown you how to use it and what problems you can solve with it. I hope this is useful for you and reduces the same pain that I have been experiencing when using string-based route navigation in the past.

A big thank you to Gerald Versluis (who also made a cool video about the Route Generator) for proof-reading this blog post for me (again). ๐Ÿ’

This blog post was written and published as part of the 2023 .NET Advent Calendar by Dustin Moris.

If you enjoyed this blog post, then follow me on LinkedIn and subscribe to this blog so you don't miss out on any future posts. If this is useful to you, maybe also consider starring the GitHub repository of the Route Generator. Thank you very much.

ย