Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Typescript

Declarative Route Path Management in Angular Apps — Even Better Than Best Route Forward

5.00/5 (2 votes)
13 Jul 2021CPOL4 min read 3.5K  
Creation of @ngspot/route-path-builder
This article goes over the creation of @ngspot/route-path-builder, a library consists of a single abstract class: RoutePathBuilder.

When I read Netanel Basal's article — “Best Route Forward — Declarative Route Path Management in Angular Apps”— I wanted to try out the solution to route path management described in the article right away in the apps I work on. The solution in Netanel’s article is intended to help with managing routes in large Angular apps. The idea is great! However, I quickly discovered that the solution does not quite work for the apps that have many feature modules with their own routes — i.e., large apps. If these feature modules have their own lazy feature modules with their own routes, a single service class really does not cut it. Let me demonstrate what I mean using a simplified example.

Here is an AppModule with the following routes:

TypeScript
const appRoutes: Routes = [
  {
    path: '',
    component: LandingComponent
  },
  {
    path: 'about',
    component: AboutComponent
  },
  {
    path: 'contact',
    component: ContactComponent
  },
  {
    path: 'products',
    loadChildren: () => import('./products/products.module').then(m => m.ProductsModule)
  }
  {
    path: 'customers',
    loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)
  }
];

There are two lazy modules for routes “products” and “customers”. The Products module contains a feature module as well. Here are the associated feature route declarations:

Products routes:

TypeScript
const productsRoutes: Routes = [
  {
    path: 'orders',
    component: OrdersComponent
  },
  {
    path: 'edit',
    loadChildren: () => import('./edit/edit.module').then(m => m.EditModule),
    canActivate: [AdminGuard]
  }
];

EditModule routes:

TypeScript
const editOrdersRoutes: Routes = [
  { path: '', component: EditOrdersComponent },
  { path: ':orderId', component: EditOrderComponent },
];

A class with methods, like in Netanel’s article, works great for a flat route structure:

TypeScript
@Injectable({ providedIn: 'root' })
export class PathResolverService {
  // two methods below are simple to declare
  about() {
    return '/about';
  }
  
  contact() {
    return '/contact';
  }
  
  // how should the feature route be declared?
  // what about child routes of that feature route?
  products() {
    // ???
  }
}

But what can be done with the routes for the lazy feature module? Below are three naive options that come to mind.

Naive Option #1

Create methods only at the top-level disregarding the nested nature of routes:

TypeScript
products() {
  return '/products';
}

productsOrders() {
  return '/products/orders';
}

productsEdit(orderId?: string) {
  const commands = ['products', 'edit'];
  if (orderId) {
    commands.push(orderId);
  }
  return this.router.createUrlTree(commands).toString();
}

Here’s how it would be used:

TypeScript
const url = this.pathResolver.productsEdit(orderId);

This approach has some clear downsides:

  • The methods for a feature module are managed within the same class.
  • The method names are long and repetitive.
  • Each child route explicitly specifies the parent /products path.
  • This will get really ugly for child routes of the edit feature module.

Naive Option #2

Have the products method return an object to attempt to represent the nested nature of routes:

TypeScript
products() {
  return {
    orders: () => '/products/orders',
    edit: (orderId?: string) => {
      const commands = ['products', 'edit'];
      if (orderId) {
        commands.push(orderId);
      }
      return this.router.createUrlTree(commands).toString();
    }
  };
}

Now, something like this can be typed:

TypeScript
const url = this.pathResolver.products.edit(orderId);

This feels a bit better, but there are still a few downsides:

  • The methods for a feature module are managed within the same class.
  • The root products route is lost.
  • Each child route explicitly specifies the parent /products path.

Naive Option #3

Create a separate class for products routes:

TypeScript
class AppRoutes {
  // ...
  
  products = new RoutesForProducts();
}

class RoutesForProducts() {
  private parentPath = 'products';
  
  orders() {
    return `/${this.parentPath}/orders`;
  }
  
  edit() {
    return new RoutesForEditOrders()
  }
}

This approach also lets the route be used like so:

TypeScript
const url = this.pathResolver.products.edit(orderId);

Now, the ability to manage child routes in separate files is gained, but the ability to use Angular’s dependency injection has been lost! The following downsides still exist:

  • The root products route is lost (could add a method root()?).
  • The explicit use of this.parentPath does not feel DRY.
  • The parentPath needs knowledge of where it is in the hierarchy of lazy feature routes. Otherwise, resulting URL will be wrong.

RoutePathBuilder

Long story short, I decided to create a solution that will keep all the benefits of Netanal’s solution and add the features I was looking for:

Original Features

  • A single source of truth for each path in the application
  • Strong typings
  • Access to Angular’s dependency injection
  • Use of absolute links (meaning, the generated link is absolute)

New Features

  • Managing routes of feature modules via separate classes
  • Use of property chaining to reflect the nested nature of the routes
  • No explicit use of parentPath in method implementations. Use of relative URL parts for the assembly of the URLs.
  • Flexible return type: to access either a url, a urlTree (useful for RouteGuards), or seamlessly navigate() to the desired route
  • A utility function to simplify the use of the this.route.createUrlTree(commands) method

Say hello to @ngspot/route-path-builder.

The @ngspot/route-path-builder library consists of a single abstract class — RoutePathBuilder. Here is how the new library will describe the routes using the hypothetical example from above.

TypeScript
import { RoutePathBuilder } from '@ngspot/route-path-builder';

@Injectable({ providedIn: 'any' })
export class AppRoutes extends RoutePathBuilder {
  products = this.childRoutes('products', RoutesForProducts);
  customers = this.childRoutes('customers', RoutesForCustomers);
  
  about() {
    return this.url('about');
  }
  
  contact() {
    return this.url('contact');
  }
}
TypeScript
@Injectable({ providedIn: 'any' })
export class RoutesForProducts extends RoutePathBuilder {
  edit = this.childRoutes('edit', RoutesForEditOrders);
  
  orders() {
    return this.url('orders');
  }
}
TypeScript
@Injectable({ providedIn: 'any' })
export class RoutesForEditOrders extends RoutePathBuilder {
  order(orderId?: string) {
    return this.urlFromCommands([orderId]);
  }
}

With this setup, inject the AppRoutes anywhere in the app and use it!

The url and urlFromCommands methods return an instance of the AppUrl class. This class has the url and urlTree properties and a navigate() method. With this in mind, here’s how the AppRoutes service can be used:

TypeScript
const url1 = this.appRoutes.products.orders().url;
console.log(url1); // "/products/orders"

const url2 = this.appRoutes.products.edit.order(orderId).url;
console.log(url2); // "/products/edit/15"

// this will navigate to the needed route
this.appRoutes.products.edit.order(orderId).navigate();

Here’s how AppRoutes can be used in a route resolver:

TypeScript
@Injectable()
export class AuthGuardService implements CanActivate {
  constructor(
    public auth: AuthService,
    public appRoutes: AppRoutes
  ) {}
  
  canActivate(): boolean | UrlTree {
    if (!this.auth.isAuthenticated()) {
      return this.appRoutes.login().urlTree;
    }
    return true;
  }
}

The RoutePathBuilder provides a root() method that returns the AppUrl for the root path of a given feature module. For example:

TypeScript
const productsRootUrl = this.appRoutes.products.root().url;
console.log(productsRootUrl); // "/products"

The RoutePathBuilder also exposes two protected properties — router and injector. The router is there as a convenient way to access the router in case it is needed without having to inject an extra service in the component or service. The injector is also there to avoid providing dependencies in the constructor. For example:

TypeScript
@Injectable({ providedIn: 'any' })
export class AppRoutes extends RoutePathBuilder {
  private ff = this.injector.get(FeatureFlag);
  
  todos() {
    return this.ff.hasAccess()
      ? this.url('v2/todos')
      : this.url('todos');
  }
}

Of course, dependencies can also be provided in the constructor, but in that case, Injector needs to be added to the dependencies and super(injector) added the to the body of the constructor.

Notice the use of { providedIn: 'any' } for the services that extend RoutePathBuilder. This means that a separate instance of that service will be created for each lazy feature module of the app. This is important because the injector should be the reference to the injector of that lazy module, not the injector of the root module. This way, accessing a service declared in the lazy feature module will not fail.

I hope you find the @ngspot/route-path-builder library helpful. I wish you happy navigating!

Special thanks to Ana Boca for reviewing, testing, and providing some of the code for this article.

In Case You Missed It

@ngspot has more goodies! For example, ngx-errors — an Angular-endorsed library for displaying validation messages in forms. More will be coming soon!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)