Angular 6 App Structure With Multiple Modules
Since I couldn't find any resources on Angular 5 app structure with multiple modules, I decided that whilst rebuilding an AngularJS app, I would implement a multiple-module architecture and document it. Later, I updated the architecture to be compliant with the Angular 6 major release. Below is the approach I took, with some justifications for the decisions I took. In the near future I plan to write more posts which get in to the more granular details of each area of the application and the motivations behind each decision.
An Angular 6 multi-module starter template is available on GitHub here.
Overview
From a high-level perspective, this is what the folder structure looks like:
app/
├── app.component.html
├── app.component.scss
├── app.component.spec.ts
├── app.component.ts
├── app.module.spec.ts
├── app.module.ts
├── app-routing.module.ts
├── core/
│ ├── core-routing.module.ts
│ ├── core.module.spec.ts
│ ├── core.module.ts
│ ├── login/
│ ├── services/
│ └── header/
├── admin/
│ ├── admin-routing.module.ts
│ ├── admin.module.spec.ts
│ ├── admin.module.ts
│ ├── admin.component.module.ts
│ ├── admin.component.spec.ts
│ ├── admin.component.html
│ ├── admin.component.scss
│ ├── manage-users/
│ └── services/
├── form/
│ ├── form-routing.module.ts
│ ├── form.module.spec.ts
│ ├── form.module.ts
│ ├── form.component.module.ts
│ ├── form.component.spec.ts
│ ├── form.component.html
│ ├── form.component.scss
│ ├── summary/
│ └── services/
└── shared/
├── shared.module.spec.ts
├── shared.module.ts
├── components/
├── models/
├── components/
├── directives/
└── services/
The AppModule
which occupies the root of the folder is purposely kept as bare as possible. It's role is simply to bootstrap the Angular application, and provide the root-level router-outlet
. This approach also leaves open the possibility of running multiple, independent Angular applications through the same base URL. It also introduces the idea of building a router-driven Angular application.
Everything runs through the CoreModule
which is placed in its own directory within the app
directory. Coming from AngularJS, it feels weird not to create a sibling directory for the CoreModule
, but this is done to keep in line with the Angular CLI conventions. Each sub-directory within the app
folder shown above should be a module.
I did experiment with following AngularJS conventions and put modules outside of the app
directory, and this works fine. It kept the root app
directory clean but raised concerns of automatic updates through the Angular CLI in the future. I found that Angular documentation, and the Angular CLI by default, created new modules within the app
directory - so this is the convention I followed.
The declaration for CoreModule
in app/core/core.module.ts
must import all other sub-modules of the application excluding the SharedModule
(unless it is actually needed - explained later). The purpose of CoreModule
is to hold the root components, services and features of the application such as a universal login screen, global navbar/header, global footer, authentication and authentication guards. Where lazy-loaded is needed, the other modules can easily be lazy-loaded in using the following code in the core-routing.module.ts
file:
{
path: 'admin',
canActivate: [AuthGuardService],
loadChildren: '../admin/admin.module#AdminModule'
},
{
path: 'form',
loadChildren: '../form/form.module#FormModule'
},
{
path: 'login',
component: LoginComponent
},
{
path: '**',
component: NotFoundComponent
}
To help simplify the concept, CoreModule
in this approach takes on the role of the root AppModule
but is not the module which gets bootstrapped by Angular at run-time.
The CoreModule
can also manage other core features for your app such as the Error 404 page through the NotFoundComponent
shown above.
The above routing definitions illustrate that the CoreModule
handles the routing of the application. In theory we should be able to import a new Core2Module
in to AppModule
which may represent a version two of the application, and the implementation of this app would have no impact on the app running via CoreModule
.
The FormModule
is publicly accessible, and the AdminModule
is protected by an Authentication Guard as illustrated in the routes shown above. The beauty of this model is that you can easily take out the AdminModule
or FormModule
without breaking any other part of your app.
For example, if you were to remove the AdminModule
you only need to delete the app/admin
directory and remove the admin route declared above. Then, any users navigating to the Admin section of the site will be shown the Not Found page via the NotFoundComponent
.
Since the application is driven by the Router
, each module has a root component which contains the router-outlet
. This is key to enabling decoupled modules (essential for clean Unit Testing), and navigation to be powered by Components and the Router
rather than ngIf
s.
Routing
Since the navigation and the rendering of components is heavily driven by the Router
in this model. The root AppComponent
contains no routes. When the application bootstraps, the CoreRoutingModule
(declared in app/core/core-routing.module.ts
) kicks in and loads the Core components.
If the user navigates to /form
the CoreModule
lazy-loads the FormModule
module along with its components and uses the routes declared in FormRoutingModule
to navigate and display content within the /form
URL. All this works by exporting the FormRoutingModule
from the FormModule
declaration, and then importing FormModule
in to CoreModule
.
If the user navigates to /admin
(which is a protected area of the application), the AuthGuardService
from the CoreModule
checks the conditions of canActivate
and only lazy-loads the AdminModule
if the user is authenticated. Similar to the FormModule
, the AdminModule
has its own routing configuration declared in AdminRoutingModule
and this controls the content displayed within the /admin
URL path.
Each submodule declares its own routes like so (AdminRoutingModule
taken as an example):
const routes: Routes = [
{
path: '',
component: AdminComponent,
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: AdminDashboardComponent },
{ path: 'manage-users', component: ManageUsersComponent }
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AdminRoutingModule { }
The root path of the AdminRoutingModule
is declared blank as this will match to the /admin
route declared in CoreRoutingModule
. It loads the AdminComponent
which is simply a html template file containing a router-outlet
. This file can also contain an Admin Navbar and other static content if you wish.
The important bit here is that the RouterModule
from the Angular library is imported as forChild
for all modules except CoreModule
. The CoreModule
imports it's routes as forRoot
.
You then also want to export the RouterModule
after initialising it with your routes so that when the AdminRoutingModule
is imported in to the AdminModule
, and the AdminModule
is imported in to the CoreModule
, the CoreModule
will have access to the routes declared in the sub-modules and they can compliment the routes declared for the Core of your application.
Sharing components, services, directives and more
If you've paid attention so far, you'll already know the answer to this. The SharedModule
is where any shared components, pipes/filters and services will go. The SharedModule
can be imported in to any other module that requires its components, pipes and/or services. Just be sure to export
anything you want to share with other modules. A reminder that services
do not need to be exported - they just need to be declared under providers
in the module declaration.
The SharedModule
doesn't have a root component or any routing declarations because it only contains components that other modules will import to use. There are no views or logic in the SharedModule
.
Be aware, though… Non of the services in the SharedModule
will be persistent. So do not use it to store data that needs to be access across various modules, but each instance of the service
imported from the SharedModule
will be different! How do we solve this? Keep reading…
Persistent services and app-wide singletons
If you are using a service
to store data while your Angular app is running you'll need to use a service which is instantiated and not discarded throughout the duration of the application runtime.
A service in the AdminModule
is only available until the user navigates out of the /admin
path. Any other module (say FormModule
that imports that service, will have a fresh version of that service as if it was instantiated with new MyService()
.
We get around this by putting services that need to be persistent in the CoreModule
because our entire app runs through the CoreModule
. From the minute the app is launched, to when the page is closed CoreModule
is being used to run the application.
That means any services declared within app/core/services
and then added to the list of providers
in app/core/core.module.ts
will be accessible to all other modules and contain persistent data.
Universal Navbar/Header and Footer
One of the biggest challenges with this model was trying to figure out how to handle displaying a dynamic header/navbar depending on which component/page was active, and changing the navbar links displayed to the user based on whether they were authenticated (logged-in) or not.
The solution is actually very obvious… Once its pointed out!
We start by creating a component
called Header in the CoreModule
(app/core/header
). This module is then declared and exported from CoreModule
as seen below:
@NgModule({
imports: [
...
],
declarations: [HeaderComponent],
exports: [
...
HeaderComponent
],
providers: [
...
]
})
export class CoreModule { }
Then, when CoreModule
is imported in to the root AppModule
, the HeaderComponent
is available as a directive because of the export above.
This means that in app/app.component.html
we now have a router-outlet
and app-header
element.
If you reload your app you will see that the header now appears at the top of every page of your application. Next, you need to make the content dynamic…
The contents of the app/core/header
folder can be as follows:
header/
├── header.component.html
├── header.component.scss
├── header.component.spec.ts
├── header.component.ts
└── navigation-links.ts
Inside the navigation-links.ts
file we can export a static Array of objects that represent all navigation menu items. This can easily be replaced by a service which retrieves objects for all the pages - but since I didn't have one available, and the navigation links for this project weren't too complicated I decided to hard-code them.
Here is a simplified version of the contents of navigation-links.ts
:
export const navigationLinks = [
{
name: 'Dashboard',
routerLink: '/dashboard',
roles: ['Admin', 'RegUser'],
order: 0,
overrideFunction: function() { console.log("override function clicked"); }
}
]
Then, within the header.component.ts
we can use the power of the Angular Router
to subscribe to URL changes to determine where in the application the user is and filter which navigation links are displayed.
Alternatively, if you are storing user token data somewhere, you can query this to determine the user's role and show only navigation links that match with the user's role.
You'll need to store the active navigation links as a class property, and within your navbar template you'll be able to loop through them all to display them. To keep things clean in the HTML, I would suggest you create a class variable called activeLinks: Array
and set its values from the navigationLinks
constant above every time the URL changes. You can subscribe to URL changes within the ngOnInit()
function using the following code:
this.router.events
.subscribe(
event => {
if (event instanceof NavigationEnd) {
this.setUserRoleFromUrl(event.urlAfterRedirects);
this.setNavLinksFromUserRole(this.userRole);
}
}
);
The same concept can be applied for a footer.
Loading spinner
One of my biggest gripes with Angular is the lack of a native loading-bar/spinner. I opted for ng-http-loader by Michel Palourdio, imported it into CoreModule
and placed the loader alongside the router-outlet
in app/app.component.html
. The plugin shows a loading spinner any time a HTTP
call is made within the application. It takes away localised spinners which result in cleaner code lower-down, and by using interceptors it uses one global spinner component to handle loading.