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

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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const productsRoutes: Routes = [ | |
{ | |
path: 'orders', | |
component: OrdersComponent | |
}, | |
{ | |
path: 'edit', | |
loadChildren: () => import('./edit/edit.module').then(m => m.EditModule), | |
canActivate: [AdminGuard] | |
} | |
]; |
EditModule routes:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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.
Create methods only at the top-level disregarding the nested nature of routes:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Have the products method return an object to attempt to represent the nested nature of routes:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Create a separate class for products routes:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
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
, aurlTree
(useful for RouteGuards), or seamlesslynavigate()
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Injectable({ providedIn: 'any' }) | |
export class RoutesForProducts extends RoutePathBuilder { | |
edit = this.childRoutes('edit', RoutesForEditOrders); | |
orders() { | |
return this.url('orders'); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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.
30