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:
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:
const productsRoutes: Routes = [
{
path: 'orders',
component: OrdersComponent
},
{
path: 'edit',
loadChildren: () => import('./edit/edit.module').then(m => m.EditModule),
canActivate: [AdminGuard]
}
];
EditModule
routes:
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:
@Injectable({ providedIn: 'root' })
export class PathResolverService {
about() {
return '/about';
}
contact() {
return '/contact';
}
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:
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:
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:
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:
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:
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:
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.
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');
}
}
@Injectable({ providedIn: 'any' })
export class RoutesForProducts extends RoutePathBuilder {
edit = this.childRoutes('edit', RoutesForEditOrders);
orders() {
return this.url('orders');
}
}
@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:
const url1 = this.appRoutes.products.orders().url;
console.log(url1);
const url2 = this.appRoutes.products.edit.order(orderId).url;
console.log(url2);
this.appRoutes.products.edit.order(orderId).navigate();
Here’s how AppRoutes
can be used in a route resolver:
@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:
const productsRootUrl = this.appRoutes.products.root().url;
console.log(productsRootUrl);
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:
@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!