The Best Way To Architect Your Angular Libraries
Learn how to architect your Angular library with sub-entries to achieve the smallest possible bundle size and long term maintainable code base!
Tomas Trajan
@tomastrajan
15.09.2020
16 min read
Sometimes it’s just worth doing it right from start 😉 (📸 by Gabriel Sollmann )
Check out video from Berner JS meetup with live coding of the concepts presented in this article!
The context
The ideas presented in this article are based on extensive experience from large enterprise environment (100 Angular SPAs and 30+ libs )…
🤫 Do you want to know how we can manage such a large environment without going full crazy 😵 Check out Omniboard!😉
To be more precise, the initial impulse to explore this topic was creation of a second “component framework” (think internal Angular Material) following this approach and comparing it to the original one which does NOT use the
ng-packagr
sub-entries…
The sub-entries facilitated tree-shaking means that when we import something, eg a MyOrgDropdownModule
then we’re only getting implementation of that sub-entry (and sub-entries it explicitly imports, eg it may use MyOrgIconModule
) and nothing else!
Think 1.5 MB vs 50 KB in such scenarios…
More so, this also works wonders in the context of lazy loaded modules (so not just main / eager vs lazy) as the sub-entries enable Angular CLI to do further optimization like extracting a sub-entry into virtual chunk that will only be loaded for particular set lazy loaded features that really use it!
What we’re going to learn
How to create Angular library implementing clean architecture from scratch (demo project included)
How to implement sub-entry per feature (and how to simplify the process using
ng-samurai
schematics)How to define proper Typescript
“paths”
aliasesHow this architecture and setup automatically saves us from using incorrect imports or introducing circular dependencies
How to analyze our library structure with
madge
How to consumer library in other applications
How to make stuff private
How to bring dependencies into consumer applications (dependencies vs peer dependencies)
How to create demo application for a library in local workspace
How to make this work with
jest
As we can see it’s quite some content so feel free to skip to the section that interests you the most based on your previous experience 😊
Let’s get started!
Step by step guide to create our library from scratch
In this section we’re going to create new Angular library project from scratch mostly with the help of *Angular CLI Schematics ** (learn more*) so no worries, there won’t be much manual typing 😉
- Generate new Angular CLI workspace (without default application)
ng new angular-library-architecture-example --createApplication false --prefix my-org
- Enter the workspace
cd angular-library-architecture-example
- Generate new library in the workspace
ng g library some-lib --prefix my-org
- Rename
name
in the nested (lib)package.json
file to@my-org/some-lib
as this does not happen automatically when specifying prefix which only works for component and directive selectors - Adjust the root
tsconfig.json
file by replacing original paths content with the following…
{
"paths": {
"@my-org/some-lib/*": [
"projects/some-lib/*",
"projects/some-lib"
],
"@my-org/some-lib": [
"dist/some-lib/*",
"dist/some-lib"
]
}
}
The first entry (with the @my-org/some-lib/*
is used during the development when referencing sub-entries from each other…
The second entry is used during the build to produce library artifacts that can be consumed by the applications in desired way…
- Delete the content of the
projects/some-lib/src/lib/
folder and remove content of the rootpublic-api.ts
file so that it’s empty.
Great! Our initial setup is ready. Let’s start creating features using sub-entries!
Now we will create our first sub-entry, and we will do it manually to learn what it’s all about. After that we will use great
ng-samurai
schematics to do it for us instead…
Create
feature-a/
folder in theprojects/some-lib/src/lib/
Create
index.ts
,ng-package.json
andpublic-api.ts
files inside of our new feature-a/ folderIn the
index.ts
file export everything usingexport * from './public-api.ts'
;The
ng-package.json
file should contain following snippet…
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "public-api.ts",
"cssUrl": "inline"
}
}
Please note that the index.ts and ng-package.json files will have the same content for every created sub-entry…
The public-api.ts
will be used to export all the entities (like modules, components, services or directives) that belong to the particular sub-entry.
Currently, we do not have such an entity yet so let’s create one using standard Angular CLI schematics ng g s feature-a/a
. This generates a.service.ts in the feature-a/
folder.
Once our service is available, we are ready to export it from the public-api.ts
export * from './a.service';
As we see, the sub-entry setup is pretty straight forward, but a bit verbose so in the future we will use ng-samurai Angular Schematics collection by Kevin which can scaffold whole sub-entries using a single command!
More sub-entries
Let’s install npm i -D ng-samurai Angular Schematics collection and use it to generate additional sub-entry for feature B using ng g ng-samurai:generate-subentry feature-b --gm false --gc false
.
The
--gm
and--gc
flags stand for generate module and generate component respectively so depending on our feature we might want to generate default module and component but we might also skip them for service only sub-entries…
Sub-entry generated using this command will come without any module or service so let’s create new service using ng g s feature-b/b
.
Tip: When implementing Angular libraries, we should almost always use
providedIn: 'root'
when creating new services (which is also a default when generating services using Angular CLI schematics). If our library consisted only of such services we would NOT need to use sub-entries as such services are perfectly tree-shakeable out of the box.
That being said, it is very likely that we will need to add some modules (and components / directives) to our library as the requirements evolve and because of that it is always best to start using sub-entries straight from the beginning when implementing any non-trivial Angular library!
OK, let’s create one more sub-entry for feature C using the approach described above together with its service, but this time we can also create module and component which we can use later when implementing library demo project…
Please make sure that we have exported every service / module / component from the appropriate public-api.ts` file of the corresponding sub-entry.
Top level exports
Once we have our features and their sub-entries ready, we still have to add their exports to top level `projects/some/lib/src/public-api.ts file.
For that we will use Typescript paths
aliases that we prepared previously so that the final export will look like export * from ‘@my-org/some-lib/src/lib/feature-a’;
. We will repeat this for every implemented sub-entry…
Checkpoint
Our initial library architecture was finished so let’s try to build the library using ng build
command.
We can explore the console output and see that there is an output for every implemented (and exported) sub-entry which will look like this…
Let’s also have a look in the dist/
folder which contains generated artifacts.
As we can see, the build created separate file for every sub-entry and an additional main file which represents the top level
public-api.ts
and re-exports all the sub-entries bundles…
This will enable us to import every exported entity from the library root
@my-org/some-lib
instead of using deep imports while preserving full tree-shake-ability!
Follow me on Twitter because you will get notified about new Angular blog posts and cool frontend stuff!😉
Composing sub-entry logic
Cool, we have our basic structure and the build is up and running, so now it’s the time to get more realistic…
In practice, it is pretty common that we would like to use some of the implemented features (sub-entries) inside of other library features (sub-entries).
A very common example of such use could be implementing
logging/
feature which might be used by the consumer application while at the same time it makes perfect sense to use it also inside of other library features likeinterceptors/
,data-access/
ortracing/
…
With our current setup we can simulate this by injecting service B in the service A. To do that we can use constructor injection and add constructor(private b: BService) { }
in the a.service.ts
file.
import { Injectable } from '@angular/core';
import { BService } from '@my-org/some-lib/src/lib/feature-b';
@Injectable({
providedIn: 'root'
})
export class AService {
constructor(private b: BService) { }
}
Depending on the editor we’re currently using it might resolve corresponding import automatically but we could always just do it ourselves and the correct import is…
import { BService } from '@my-org/some-lib/src/lib/feature-b';
We’re referencing a service from another sub-entry and because of that we’re using Typescript path alias instead of a relative import.
Let’s try to build it using ng build
and everything should work just fine!
Now is the time to start exploring all the architectural benefits that we’re getting for free just because we’re using sub-entry approach!
Circular dependencies between sub-entries
Previously we were injecting service B in the service A so let’s try to do the opposite and inject service A in the B to create a circular dependency and try to build the project again using ng build
.
🅰️➡️🅱️➡️🅰️
We’re going to see an error in the build output…
This is a very nice and helpful error which should help us pinpoint the real cause of our problem in no time. Unfortunately this is not the case under all the possible circumstances…
Let’s say we would introduce circular dependency through feature C
🅰️➡️🅱️➡️©️➡️🅰️ (such a letdown from emojis, no nice C 😿 is available)
Depending on our version of Angular CLI we might get nice descriptive error, Out of Memory crash or even nothing (eg build fails silently and does not produce any Javascript files in the dist/ folder, room for an improvement ?)
Any such occurrence signifies that we have introduced circular dependency and hence going in the direction of tangled hard to maintain code base where everything depends on everything! This represents a great opportunity to prevent it from happening!
Let’s fix our problem by reverting back to 🅰️➡️🅱️➡️©️ and it will work just fine! To do that, we have to remove injection of service A from service C and rebuild our library using ng build
.
You might have noticed that the sub-entries in the build log are ordered based on the dependency graph so that we first build our leaf sub-entry (feature C) all the way up to library root
public-api.ts
.
Exploring the dependency graph
We have been speaking about the circular dependencies and a dependency graph in general. We have also provided simple visualization with the help of an emoji based dependency chains but that doesn’t really scale for any real life project…
Luckily there is a great open source tool called madge which can generate dependency graph as a nicely formatted picture!
Beware, it may be a bit hard to install it depending on your platform, cough Windows cough 😅…
Once we installed madge
globally using npm i -g madge
(and also all the necessary environment dependencies) we can run it using madge projects/some-lib/src/public-api.ts --ts-config tsconfig.json --image graph.png
and the result should look something like this…
If we reverted back to the circular dependency described above 🅰️➡️🅱️➡️©️➡️🅰️ and re-run the madge
we’re going to get picture that looks a lot different!
The rule is pretty simple, red = bad = circular dependency!
Guaranteed correct imports out of the box
Another great feature of this setup is that it forces us to use correct imports when consuming entities across the sub-entry boundaries…
Let’s say that we try to import service C in service B. The correct way to do this would be using import { CService } from '@my-org/some-lib/src/lib/feature-c';
but what if our IDE generates (or we by mistake write) import { CService } from '../feature-c/c.service';
instead?
Such an import means we’re trying to import something from other sub-entry using a relative import syntax
If we tried to build our library using ng build
we would end up with the following error…
The key part to focus on here is the presence of the rootDir
keyword.
Whenever we get such an error we can be sure were trying to import something relatively across the sub-entry boundary and fix the problem!
As we can see, using sub-entries come with many build in checks that ensure we’re always doing the right thing!
How to consume library in the Angular applications
Our setup enables us to import everything from the root @my-org/some-lib
regardless of which sub-entry is the location of the source file of given service, component or module.
This can be combined with a custom import-blacklist
Tslint rule to prevent deep imports from our libraries eg @my-org/some-lib/(?!testing).*.
This will ban every deep import from our library besides
@my-org/some-lib/testing
which can be a great starting point to guarantee that you are consuming your library in a correct way!
With all this in place we should be able to use something like service A using import { AService } from '@my-org/some-lib';
.
How to make stuff private
One of the requirements to make this setup work is that we have to export everything implemented in a sub-entry in its public-api.ts
file.
This can be problematic if we have a lot of internal (private) implementation which we do NOT want to expose to consumers as they might be tempted to use it just because it was available.
Luckily there is a way to solve it. In the previous section we discussed way to prevent consumers from using deep imports with the help of Tslint import-blacklist
rule.
We can make stuff private by creating dedicated “internal” sub-entries (eg internal-utils/
) which will NOT be exported from the root public-api.ts
file.
The only way to consume such sub-entry would be using deep import like @my-org/some-lib/internal-utils
which is forbidden by the previously created Tslint rule and would lead to a linting error so we’re safe!
This approach works but seems a bit too much effort to achieve commonly needed thing like privacy so maybe another room for improvement?
How to bring dependency into consumer
Sometimes our library uses some other 3rd party libraries like date-fns
. In such situation we have two main options how to make sure that the consumer application will get such dependency and both options come with their own trade-offs.
1. Dependency will be brought by the library
Our dependency can be declared as a dependency in the **INTERNAL **library package.json
file (we also have to add it to the whitelistedNonPeerDependencies
it in the ng-package.json
file). That way whenever we install our library in some application we will also install that 3rd party dependency for that application.
The good thing about this approach is that it simplifies the setup for the consumer apps. Developers do not have to read and follow installation setup documentation or notice missing
peerDependency
warning duringnpm install
…
The problem with this approach is that consumer might already use different version of the same 3rd party library which might lead to variety of problems when the public API changes (breaks) between major releases of the 3rd party library…
Another big potential problem with this approach is that even if everything works just find we might end up with multiple copies of such library in our application bundles which would hurt startup time for our users many thanks to Alan Agius for providing feedback to enhance the content of this article!
2. Dependency will be declared as a peerDependency
Another option is to declare 3rd party library in the peerDependencies
in the internal package.json
file.
The consumer application is then responsible for installing and providing that 3rd party dependency which is bit more work for the developers on the consumer side but more safe in terms of life-cycle management!
Demo app to showcase or test library in the same workspace
Sometimes it can be valuable to showcase library functionality in the locally included demo application. Such application can be generated in the same workspace using Angular Schematics ng g application some-lib-demo
.
The demo application then has to consume entities provided by the library by importing its modules, components and services…
Based on previously defined Typescript paths
aliases we have two main options for importing of the library code in the demo app.
We can use standard @my-org/some-lib
imports (same as in normal consumer apps) but for that to work we would have to first pre-build our library using ng build
(so that its bundles are available in the dist/
)
Second option is to use @my-org/some-lib/src/lib/<sub-entry-name>
in which case running or building of the demo app is possible also without pre-building of the library.
This approach will also live reload on any changes to the library code so depending on your development style it might be preferable to the need to pre build library (eg if you are building component heavy library and use demo as an development enhancement tool).
Bonus: Make it work with Jest 🃏
Jest is unfortunately implemented in a way that prevents its direct integration as a part of Angular CLI build pipeline. It’s also the main reason why there is no official Angular CLI Jest support as the CLI team would have to support two separate pipelines…
Of course, we can use Jest to test our library which was implemented using the architecture described in this article. All we have to do is to add moduleMapper
property in the jest.config.js
which closely follows aliases that we defined in the main tsconfig.json
file!
{
moduleNameMapper: {
'@my-org\\/some-lib\\/(.*)': '<rootDir>/$1',
}
}
The exact value after <rootDir>
depends on the location of your jest.config.js
file. In the example above our jest.config.js
file is located inside projects/some-lib/
folder which means that our paths of src/lib/feature-a
will map correctly as $1
to the real file location.
Bonus: In-depth view of what is going on
One of the issues that we have faced led us to discussion with fellow GDEs and even Angular team members which mentioned various amazing resources which can help us to get even better understanding of how and why sub-entries (and tree-shaking) works in Angular / Typescript…
Many thanks to George Kalpakas @gkalpakas, Alan Agius @AlanAgius4, Pete Bacon Darwin @petebd and Joost Koehoorn for all the input!
Angular Optimization Pipeline (+
check-side-effects
)Typescript explicit vs
*
imports from Webpack tree-shaking perspective
EDIT: Do we need sub-entries when working with IVY? Yes! (21. 07. 2021)
Everything in here will be based on latest Angular 12.1.2
with IVY. Also, as I have learned, it is no longer possible to disable IVY in the latest Angular version anyway…
CommonJS Dependencies
They wont be tree shaked without using of sub entries and will end up in your main.js, even if you do not use the library component which imports it at all…
Code splitting
Let’s imagine a scenario when we have a library with three modules, A, B and C which all have single component per module, A, B and C…
Now let’s imagine that component C
has a 100 KB
string bound to its public property and uses it in its template so we can easily detect in which bundle it lands based on the particular configuration…
Scenario 1: Library modules are only used in the lazy modules of consumer Angular SPA
What we’re going to get in this situation is almost good. What we would have expected is that the 100 KB string is bundled into the LazyModuleC
of the consumer SPA where it was used but it will end up in extracted common chunk as the bundler can’t really figure out if its not used in the other lazy loaded modules…
Scenario 2: Library modules are used in both eager AND lazy modules of consumer Angular SPA
This example will demonstrate that NOT using sub-entries breaks code splitting and causes library module C with its component C (100KB) to end up in the main.js
even though it was only imported in LazyModuleC
of the consumer…
Sub-entries + IVY Summary
Sub-entries are still necessary even when working with IVY
Without sub-entries the following will break:
tree shaking of the CommonJS dependencies
code splitting (eager / lazy boundary)\
This applies at least as of Angular 12.1.2
Great, we have made it to the end! 🔥
I hope you enjoyed learning about the best way how to architect your Angular libraries and what benefits you can get by implementing ideas described in this article! Check out the demo project!
Also, don’t hesitate to ping me if you have any questions using the article responses or Twitter DMs @tomastrajan.
And never forget, future is bright
Do you enjoy the theme of the code preview? Explore our brand new theme plugin
Skol - the ultimate IDE theme
Northern lights feeling straight to your IDE. A simple but powerful dark theme that looks great and relaxes your eyes.
Do you enjoy the content and would like to learn more about how to ensure long term maintainability of your Angular application?
Angular Enterprise Architecture eBook
Learn how to architect a new or existing enterprise grade Angular application with a bulletproof tooling based automated architecture validation.
This will ensure that Your project stays maintainable, extendable and therefore with high delivery velocity over the whole project lifetime!
Get notified
about new blog posts
Sign up for Angular Experts Content Updates & News and you'll get notified whenever I release a new article about Angular, Ngrx, RxJs or other interesting Frontend topics!
We will never share your email with anyone else and you can unsubscribe at any time!
Responses & comments
Do not hesitate to ask questions and share your own experience and perspective with the topic
Tomas Trajan
GDE for Angular & Web Technologies
I help developer teams deliver successful Angular applications through training and consulting with focus on NgRx, RxJs and Nx!
Ein Google-Developer expert für Angular und Webtechnologien, der als Berater und Angular-Trainer arbeitet. Derzeit unterstützt er weltweit Teams in Unternehmen bei der Implementierung von Kernfunktionalitäten, Architekturen, der Einführung von Best Practices, und der Optimierung von Arbeitsabläufen.
Tomas ist ständig bestrebt, seinen Kunden und der breiteren Entwicklergemeinschaft einen maximalen Nutzen zu bieten. Seine Arbeit wird durch eine umfangreiche Erfolgsbilanz unterstrichen, in der er populäre Fachartikel veröffentlicht, Vorträge auf internationalen Konferenzen und Meetups hält und zu Open-Source-Projekten beiträgt.
52
Blog posts
4.7M
Blog views
3.5K
Github stars
612
Trained developers
39
Given talks
8
Capacity to eat another cake
You might also like
Check out following blog posts from Angular Experts to learn even more about related topics like Angular !
My new Angular Coding Style
Embracing the New Angular: A Fresh Perspective on how to write Angular code.
Kevin Kreuzer
@kreuzercode
02.12.2024
7 min read
Top 10 Angular Architecture Mistakes You Really Want To Avoid
In 2024, Angular keeps changing for better with ever increasing pace, but the big picture remains the same which makes architecture know-how timeless and well worth your time!
Tomas Trajan
@tomastrajan
10.09.2024
15 min read
Angular Signal Inputs
Revolutionize Your Angular Components with the brand new Reactive Signal Inputs.
Kevin Kreuzer
@kreuzercode
24.01.2024
6 min read
Stärken Sie Ihr Team mit unserer umfassenden Erfahrung
Unsere Angular Experten haben viele Jahre damit verbracht, Unternehmen und Startups zu beraten, Workshops und Tutorials zu leiten und umfangreiche Open-Source-Ressourcen zu pflegen. Wir sind sehr stolz auf unsere Erfahrung im Bereich des modernen Frontends und würden uns freuen auch Ihrem Unternehmen zum Aufschwung zu verhelfen.