As we can see, we’re using quite some new components and directives which we have NOT yet imported in the CoreModule so we have to add them else our build will fail…

Tip: Angular module is basically a “context” for the components it implements. This means that every component that we want to use in the template of our component ( like <mat-toolbar> ) must be a part of the module which declares our component. What does it mean to be part of? It either has to be in the declarations: [ ] of our module OR it has to be in the exports: [ ] of the module we import. In our example, we want to use <mat-toolbar> which is in the exports: [ ] inside of MatToolbarModule so we have to add that module into the imports: [ ] of our CoreModule as in the example below…

Please notice helpful import grouping and comments like // vendor and // material … They are NOT mandatory but nice to have because they let your colleagues (or even you in the future) get a quick overview of the module structure compared to long randomly sorted list of imports!

Let’s add a bit of styling in the main-layout.component.scss to make it look more like our previous example…

Great, we imported modules that export all the components and directives we used inside of our component template and the application should be up and running again!

The Lazy Features

We have created the main layout and now is time to generate lazy loaded features!

Tip: We will generate lazy feature for every top level route of our application. This is a reasonable starting point but don’t forget that it is possible to lazy load nested routes if the feature gets too big!

First, we will generate module for our home feature using ng g m features/home --route home --module app.module.ts which will do two things:

generate module, routing module and component files in /features/home add lazy route to the main app-routing.module.ts file

Let’s have a look in the app-routing.module.ts file. We can see our generated home route. Let’s add first “empty” route which will redirect to home (to show something from the start).

Tip: We can also add last “catch all” ( ** ) route. This route will redirect to home in case we’re navigating to a route which does NOT exist! It is also possible to implemented dedicated “not found” route instead of redirecting to home…

Angular routing config example with generated “home” lazy route, initial redirect and catch all route

Now we can do the same for our admin route using ng g m features/admin --route admin --module app.module.ts . Notice that it was correctly added before “catch all” route, else it would never trigger!

Highlight active route

Now we have implemented both the navigation and the routes so we can run our application and see it in action. It will correctly switch routes as we keep clicking on the buttons is the top toolbar.

It is good UX to show user which route is currently active

Let’s open main-layout.component.html file and add routerLinkActive="active" directive on both navigation buttons.

Then we can implement .active class in the main-layout.component.scss file for example using filter: brightness(<amount>); rule which is cool because it does not force use to chose color so it can work for any theme !

Great our application now has two lazy loaded features and we could easily add more by repeating the steps above!

The Benefits (why is lazy loading so great)

Most people are aware that lazy loading decreases the size of initial Javascript bundle and hence speeds up application startup time. This is definitely great BUT lazy loading comes with MANY MORE benefits than that!

developer feedback loop ( aka faster rebuilds in DEV mode )—with lazy loading, when we make a change to some file, Angular CLI has to only rebuild MUCH smaller lazy bundle to which that file belongs (hundreds of KBs) compared to single huge main bundle (couple of MBs), this can make a difference from some to many seconds on every re-build based on application size!

( aka faster rebuilds in DEV mode )—with lazy loading, when we make a change to some file, Angular CLI has to only rebuild MUCH smaller lazy bundle to which that file belongs (hundreds of KBs) compared to single huge main bundle (couple of MBs), this can make a difference from some to many seconds on every re-build based on application size! isolation — with lazy loading we’re guaranteed that feature A can NOT access and use code implemented in feature B because that code is physically not present in the browser yet so it will fail at runtime! This means we can easily extract features to libraries or even delete them altogether without breaking the rest of our application.

— with lazy loading we’re guaranteed that feature A can NOT access and use code implemented in feature B because that code is physically not present in the browser yet so it will fail at runtime! This means we can easily extract features to libraries or even delete them altogether without breaking the rest of our application. guarantees — based on what we discussed in isolation, we can also assume that making changes to a code in feature A can NOT have influence and hence break other features which gives us more confidence when evolving our code base.

— based on what we discussed in isolation, we can also assume that making changes to a code in feature A can NOT have influence and hence break other features which gives us more confidence when evolving our code base. faster application start up time — most people put this as THE first thing and it is in fact very important but don’t forget its not the only benefit of lazy loading!

What to implement in lazy features

Lazy features will contain implementation of declarables (components, directives and pipes) that are specific to that feature. For example the views or some specific component which can NOT really be re-used by other features.

Tip: When we generate services using ng g s <path>/<service-name> the resulting service will be providedIn: ‘root’ by default which is not the best solution for feature specific service. Such a solution would NOT prevent importing of that service by other features and hence breaking feature isolation!

Feature specific services can be scoped to that feature by removing the providedIn: 'root' from their @Injectable() decorators and adding them to the providers: [ ] array of the lazy feature module instead!

The Shared Module

Our application now has core and two lazy-loaded feature modules ( home and admin ). As we start adding functionality to our lazy features we may realize that some of them need to use same component, directive or pipe…

This is the perfect use case for the SharedModule ! Let’s create it using ng g m shared . Now what should we put into it?

declarables (components, directives and pipes) which we want to use in multiple lazy features

components from libraries (vendor / material / your component framework)

re-export CommonModule (it implements stuff like *ngFor , *ngIf , … )

Example of the SharedModule structure

Once we have SharedModule ready we can use it in our lazy loaded feature modules and remove CommonModule as it is now exported by the shared module itself.

⚠️ TIP: Shared module will be imported by many lazy loaded features and because of that it should NEVER implement any services ( providers: [ ] ) and only contain declarables (components, directives and pipes) and modules (which only contain declarables). The reason for that is that every lazy loaded module would get its own service instance which is almost never what we want because in most cases we expect services to be global singletons! If we want create “shared” services used in many parts of our application we should implement them in the /core folder and use providedIn: 'root' syntax without putting them in providers: [ ] of any module…

Great our architecture is finished, now we are able to focus purely on delivering features for our users!

Trade offs

It is important to acknowledge there is no silver bullet. The presented solution comes with it’s own set of pros and cons. Presented architecture strives to strike a nice balance between bundle size and developer experience (DX) based on real life observations from many projects…

That being said, feel free to adjust it based on your personal preferences and particular use case of your project which should always be the main criterion when making decisions

It can look different based on the composition, size and tree-shake-ability (wow these frontend words 😅) of the libraries you will be using in your particular project!

Application size vs Developer experience (DX)

smallest bundle possible —no SharedModule module at all, every module ( CoreModule and lazy features) imports EXACTLY what they need. We get the smallest possible bundles with most optimization but the developers will have to maintain HUGE lists of imports which will hurt the most when implementing tests as every component test has to build appropriate context by importing what is necessary into the TestBed

—no module at all, every module ( and features) imports EXACTLY what they need. We get the smallest possible bundles with most optimization but the developers will have to maintain HUGE lists of imports which will hurt the most when implementing tests as every component test has to build appropriate context by importing what is necessary into the simplest possible DX — there is only one module, the AppModule and everything is imported there, developers don’t have to think about contexts, everything is available everywhere, the application bundle is huge and we get “big ball of mud”

— there is only one module, the and everything is imported there, developers don’t have to think about contexts, everything is available everywhere, the application bundle is huge and we get “big ball of mud” small bundles, reasonable DX — using architecture and SharedModule as we described above. It will import and re-export components from 3rd party component libraries together with re-usable local components. We will import it in every lazy feature and in the component tests.

That way we have reasonable bundle size together with nice DX where we don’t have to add 50 lines of imports in every feature module and component test file which is good!

Great, we have done it!

We have created an Angular application with amazing clean extensible architecture in a very short time! Check out the repository with application built based on this guide!

We did it with the help of Angular CLI where we generated project and basic structure using Angular Schematics.

Our application has CoreModule for application wide singleton services, base layout and any other stuff which we will need from straight from application startup. We’ve also built SharedModule for reusable components, pipes and directives (declarables) that will be used by lazy features (BUT NOT the core!) Lastly we have created couple of lazy loaded modules (with respective routes) that will implement the feature business logic (services) and views (components) that are specific to that feature…

From here, we will proceed by following that architecture and adding more features for our users!

Good luck with your projects!

Please support this article with your 👏👏👏 because it helps it to spread to a wider audience 🙏 and follow me on Twitter to get notified about the future articles!

Also, don’t hesitate to ping me if you have any questions using the article responses or Twitter DMs @tomastrajan.