eBook Angular Enterprise Architecture Tomas Trajan Angular Experts v2
eBook Angular Enterprise Architecture Tomas Trajan Angular Experts v2
Architecture
Learn how to set up a new Angular enterprise project in
a scalable, maintainable way with automated
architecture validation to ensure long term success of
your project!
Tomas Trajan
@tomastrajan
v2.4.0 ©2024
Page internationally left blank
2 Tomas Trajan
@tomastrajan
Table of content
About author 10
Foreword 11
Acknowledgements 11
New experience and lessons learned 13
Reasons for releasing v2 of the ebook 14
Optional NgModules 14
Esbuild 14
Automated architecture validation 15
Main differences between v1 and v2 of the book 15
Theory 16
Why clean architecture matters? 16
Typical scenario of an average Angular project 17
Chaotic dependency graph 18
Clean dependency graph 20
Hope based architecture 22
Angular Enterprise Architecture teaser 23
Base underlying reality 24
The browser 24
The bundler and the bundles 26
Angular 28
Standalones (previously declarables) 28
Template context 29
Injectables 31
Injector hierarchy 32
Other injectors 35
Function based logic 35
Eager vs lazy part of the application 37
Eager part 37
Lazy part 37
Routing based lazy loading 38
Defer based lazy loading 39
3 Tomas Trajan
@tomastrajan
Table of content
Key concepts 41
Lazy loading 41
Lazy loading benefits 41
Faster start up times 42
Faster developer feedback loop 44
Isolation 45
Lazy loading heuristics 48
Isolation 50
Isolation guarantees 53
Local impact 53
Local testing 53
Isolation improves extensibility and maintainability 54
Architecture promotes isolation 54
Feature as a black box 54
One way dependency graph 56
Implementation hints 57
State vs view logic 57
Correct level of abstraction 59
Framework vs application business logic 59
Short term vs long term thinking 59
Optimizing for the wrong thing 60
Common negative consequences of adding abstractions 61
Business logic related 61
Framework related 62
Correct level of abstraction 62
How to handle verbosity 63
Summary 64
4 Tomas Trajan
@tomastrajan
Table of content
Angular Enterprise Architecture 65
The big picture 67
Types overview 67
Core (eager) 68
Standalone core 69
Angular (and state management) specific providers and setup 70
Infrastructure specific providers 70
Initialization logic and kick-starting of processes 70
App specific core logic needed from start 71
App specific core logic shared by more than one lazy feature 72
Grouping core logic in folders per domain 73
Other utils 73
Providers from NgModules 74
Layout (eager) 75
Single main layout used by the whole app 76
Multiple layouts 77
Custom layout per feature 78
UI (eager / lazy) 79
Standalones only 80
Generic reusable UI components 81
Eager / lazy bundling implications 82
How to handle types and interfaces 84
How to handle complex pipes 87
Feature (lazy) 89
Isolation and the "black box" or "throw-away" nature of lazy features 90
Nested lazy features 91
Fractal nature of the lazy sub features 93
How to share logic between multiple lazy features 94
Types of logic that we might want to share 94
The "extract one level up" rule 95
Example of sharing logic between lazy features 98
Sharing UI components between lazy features 98
Sharing NgRx state slices between lazy features 100
Sharing patterns between lazy features 102
Sharing lazy features themselves 103
Why are there no eager features 105
5 Tomas Trajan
@tomastrajan
Table of content
Angular Enterprise Architecture 65
The big picture 66
Types overview 67
Pattern (lazy / eager(rare)) 107
Why do we call it pattern or a "drop in" feature 108
Pattern vs ui, core and feature 109
When to create a pattern 110
Pattern examples 113
Document manager 113
Approval process 113
Change history (audit log) 114
Notes / comments 114
Helper types 115
App (eager) 116
Main (eager) 116
Ignoring infrastructure types 117
Relationship between types 118
The most common dependency graph issues 122
Automated architecture validation 124
The eslint-plugin-boundaries anatomy 125
Basic setup 125
Types definitions 126
Rules definitions 132
Configuration of rules per project 137
New project bs existing project (strictness) 137
Architecture vs implementation 138
Limitations and alternatives 139
Ideal fit 140
6 Tomas Trajan
@tomastrajan
Table of content
Hands on architecture 141
Setup and project creation 141
Setup environment 141
Create new workspace 143
Setup IDE (Webstorm, VS code) and generate application 143
Prettier support 147
Bundle size analysis (and budgets) 149
Dependency graph analysis 153
Eslint support 155
Schematics support 157
Schematics pre-configuration 161
3rd party schematics 162
Automatic migrations with ng update 163
Component library and styles utils 164
Add Angular material 164
Add Tailwind 167
Final cleanup 169
Scaffold architecture skeleton 170
Preparing folder structure 170
Core 170
Layout 172
Feature 176
What about "Shared Feature" 179
UI & Pattern 179
Setup automated architecture validation 180
Adding more types and rules 188
Example project 191
Growing the workspace and collaboration 192
Adding other apps and libs 192
Collaborating with multiple teams 192
7 Tomas Trajan
@tomastrajan
Table of content
Wrap up 194
Links 196
The eBook and example project 196
References from the content 196
Tools and libraries 197
Contact and social 198
Offered products, workshops and services 198
Resources 199
8 Tomas Trajan
@tomastrajan
Page internationally left blank
9 Tomas Trajan
@tomastrajan
About the author
Tomas Trajan
Google Developer Expert for Angular & Web Technologies
@tomastrajan
public
10 Tomas Trajan
@tomastrajan
Foreword
In this book we are going to learn how to scaffold a new enterprise-ready
Angular application with clean, maintainable and extendable
architecture and make sure it stays that way with the help of automated
tooling-based architecture validation!
The content of the book is structured in a couple of main parts which will
helps us to first build an understanding of the underlying concepts and their
influence on the proposed architecture.
Then we’re going to explore each part of the architecture in depth, and finally
we’re going to use everything we’ve learned hands-on to create a new
Angular workspace, application and scaffold the architecture from scratch!
Just like in the example repository which showcases the end result!
11 Tomas Trajan
@tomastrajan
Foreword
thumb_up All the future updates of the content will be available for free to
everyone who already purchased the eBook! Usually in the form
of email with the short description of what's new and a download
link for the updated version.
12 Tomas Trajan
@tomastrajan
Acknowledgements
This book is only possible because of the amazing Angular community whose
members have been very generous with sharing all the know-how and
experience they have gained over the years. This continuous exchange and
challenging of ideas brings the whole ecosystem forward!
I would like to thank all the people who have contributed to this book. A
special thanks to Kevin Kreuzer for long-term collaboration and feedback on
all things Angular. Many thanks to Tomasz Ducin for great ideas and Daniel
Glejzner for ongoing feedback.
13 Tomas Trajan
@tomastrajan
Reasons for releasing v2 of the eBook
This is the second major version of this book. The reason I went with overall
rework instead of incremental updates is that there have been lots of very
impactful developments in Angular starting with v14 with the introduction of
standalone APIs.
Optional NgModules
This allows for managing declarables like components, directives and pipes
without NgModules as well as a new simpler approach to creating and
configuring applications with the new “provideX” approach.
This change has made NgModules optional and therefore has a significant
impact on the way we architect Angular applications.
14 Tomas Trajan
@tomastrajan
Reasons for releasing v2 of the eBook
Automated architecture validation with eslint
Being able to automate architecture validation and guarantee that it stays
the way it was intended over the lifetime of the project is a major
improvement that increases development velocity and the odds of project
success!
15 Tomas Trajan
@tomastrajan
Theory
The first part of this book will help us build up theoretical understanding and
mental models because a strong foundation will allow us to make sense of the
approaches and tradeoffs of the proposed architecture approach.
In other words, it’s always a good thing to understand why we’re doing what
we’re doing, which is vastly superior to just following the provided steps. This
will help us keep making the right decisions once our project grows beyond
the most basic setup!
16 Tomas Trajan
@tomastrajan
Theory
A typical scenario of an average Angular project
At the beginning of the project, a developer or a team of developers is able
to move quickly and ship new features with expected velocity.
Over time, the technical debt, especially in the form of ever-increasing
coupling between the unrelated parts of the code base, will start to have a
noticeable impact on delivery speed.
The new features and updates to existing features will take more time and
often introduce bugs in what is from the outside perceived as a completely
unrelated feature of the application!
We have seen this time and time again in various code bases (both apps
and libs) within many organizations. As such, it’s a predictable trajectory
for most of the Angular applications without a clear idea about how the
architecture should look like and how to maintain it over time.
This book will allow us to build a strong foundation together with automated,
tooling based architecture validation which can preserve development
velocity as the project enters the mid to long-term stages of its lifecycle!
Let’s explore a couple of examples of what tends to happen in real life
applications without much thought about the architecture…
info Following examples will use madge , a great node.js based tool
for visualizing of dependency graphs.
17 Tomas Trajan
@tomastrajan
Theory
Chaotic dependency graph
The madge is one of the best tools that we can use to evaluate the overall
health of the code base from the perspective of its dependency graph. We’re
going to discuss how to add it to the project later in the hands on
architecture part of this book.
Right now, we’re going to use it to explore example visualizations which
showcase how it looks like when architecture gets more and more tangled
over time.
18 Tomas Trajan
@tomastrajan
Theory
Actual names of the files don’t really matter, but it is immediately obvious
that the dependency lines are all over the place.
More so, the red nodes represent actual cycles in the dependency graph,
which is a sign of bad architecture and increased coupling.
Please take a deep breath before looking at the next dependency graph,
this is from a real project!
Few files made it to the cutout (maybe 5% of the overall dependency graph),
but the dependency graph edges are completely out of control!
19 Tomas Trajan
@tomastrajan
Theory
It takes little imagination to understand that extending such a project with
new features or adjusting of the existing one turns into month-long pain of
new regressions popping up at unrelated places. The main reason for that is
that everything depends on and knows about everything else!
The cutout of the dependency graph has no cycles (red nodes), even a lazy-
loaded feature is clearly recognizable (bottom), there is a general sense of
“one-way-ness” from left to right.
20 Tomas Trajan
@tomastrajan
Theory
As we can imagine, maintaining and extending such a project will prove much
easier compared to the examples depicted above.
To make the point even stronger, the last dependency graph is from a
project called Omniboard - a SaaS product that helps you to analyze,
understand and manage codebases in large enterprise environments
and which is definitely of medium (to large) size developed by a single
developer (me).
As we can imagine, being able to ship features fast, even 4 years after the
project kickoff is of vital importance and the clean architecture allows me to
do just that!
Another example of a cutout of a desirable clean one way dependency graph
from a library from one of our customers can be found below.
21 Tomas Trajan
@tomastrajan
Theory
Hope-based architecture
As we discussed previously, it is 100% possible to keep the architecture clean
over the lifetime of the project, but it is at best unusual. Projects often start
with many good ideas and folder structure, which tries to set the correct
tone for the development.
info It is 100% possible to keep the architecture clean over the lifetime
of the project, but it's much easier with help from tooling!
And on rare occasions, the team’s overall skill level and attention to detail will
be enough to preserve it in the long term…
But without the support of dedicated automated tooling, we’re just hoping
that it stays that way and everyone involved will pay attention and review
perfectly 100% of the time, which is just highly unlikely.
In this book we’re going to learn how to stop relying on hope that everybody
does a perfect job 100% of the time and take that burden away with the
eslint-plugin-boundaries which will act like a friendly senior reviewer who
will have our back each time we want to merge something into our
repository!
Now it’s time to have a glimpse of what we’re going to learn to prime our
curiosity!
22 Tomas Trajan
@tomastrajan
Theory
Angular Enterprise Architecture teaser
That’s it, we’re done, you’re ready to implement robust, maintainable and
extensible Angular applications for your organization!
But where would be the fun in that, so let’s make sure that everything from
this diagram makes perfect sense and falls naturally into its place. For that, we
have to embark on a journey, and the first stop is building a solid foundation
of base concepts!
23 Tomas Trajan
@tomastrajan
Theory
Base underlying reality of the browser,
the bundler and Angular
Now it’s time to explore and learn about some of the main technologies
which will have the largest impact on the way the proposed architecture
looks and the way it works.
In this way, I like to think about it as the “base reality” which we have to take
into consideration so that we can end up with a strong foundation on which
our project can stand with confidence!
The browser
Angular applications will run in the browsers of our users. When a user wants
to use our application, then the user and the browser have to perform a
couple of steps which can be summarized as:
1. A user enters the application URL in the URL bar (manual / bookmark / …)
2. The server responds with the index.html file which contains references
to the eager JavaScript bundle files
3. The browser triggers loading of these bundles
The loading of the bundle will take some time, depending on the
network conditions
The server should preferably serve compressed (gzip) bundle files that
reduce the number of bytes that have to be transferred over the
network
24 Tomas Trajan
@tomastrajan
Theory
The bundles usually contain hashes in their file names, which allows
browsers to cache them safely (without risk of using outdated bundles
when the new version of the app was released)
In general, network is rarely a limiting resource for most users, and later
caching of the bundle files removes the “network time cost” entirely
(until the next release of the application)
4. Once loaded, the JavaScript is parsed and executed, and the Angular
application is bootstrapped
The parsing and execution can take a considerable amount of time, even
on desktop computers today
The parsing and execution represent the main bottleneck and will be
affected the most by increased eager bundle size as a result of bad
architecture
5. The application then triggers loading of additional lazy bundles (e.g. for
the initial feature based on the exact url) or later based on user interaction
(e.g. navigation to another lazy feature using a nav item or
programmatically)
25 Tomas Trajan
@tomastrajan
Theory
The bundler and the bundles
Now that we established that the JavaScript bundles and their size play a
significant role in the overall application startup story and can make or break
the user experience, let’s explore how bundles come into existence.
Each Angular project consists of many Typescript files, and these files are
referencing each other with the help of imports and exports (JavaScript
module syntax).
Today the browsers already support JavaScript modules and would allow
for native loading of all the referenced files, but there is one huge
problem with this approach. The browser can’t know which files to load
before it loaded previous files which reference them.
Because of this, native loading of JavaScript modules would turn into a huge
waterfall of requests and would lead to a terrible startup performance, even
with use of something like HTTP2 or streaming!
Because of this reason, virtually all frontend frameworks use some kind of
tooling like webpack or esbuild to pre-bundle these files into larger
JavaScript bundle files to be loaded by the browser in a much faster manner.
With one extreme of the spectrum being using browser to load individual
JavaScript files with native module support, the other extreme would be a
huge JavaScript bundle file that will contain all the source code of our
application.
This would be also suboptimal because the large bundle will take a non-trivial
time to be transported over the network.
26 Tomas Trajan
@tomastrajan
Theory
But even more problematic, the browser will then need to parse and execute
all the JavaScript in that bundle file in one go! This can take a couple of
seconds even on the most powerful modern desktops and would lead to
prohibitively slow startups on virtually all mobile devices.
27 Tomas Trajan
@tomastrajan
Theory
Angular
The last piece of our puzzle which sets the constraints on what can function
as a good architecture is Angular itself.
Angular comes with many well-defined concepts like routes, components,
directives, pipes or services which are very useful and provide us with a solid
frame when thinking about how to implement any particular user flow and
corresponding UI.
For example, it’s immediately clear that the actual UI has to be implemented
using the components because they have a template. On the other hand,
backend requests, data transformation or state handling fit better into
services because they do not have to deal with templates and allow us to
focus on the data.
These base building blocks, like components or services, …, then play by the
rules set of the two main underlying systems in Angular itself and can be
grouped into two main groups, the standalones (previously declarables) and
injectables.
The previous “declarables” name comes from the older NgModules based
API where each declarable had to be an explicit part of the
declarations: [ ] array of exactly one NgModule .
28 Tomas Trajan
@tomastrajan
Theory
Nowadays, with the advent of standalone APIs, the declarables are no longer
declared in the parent NgModule but are “declared as standalone” with the
help of the standalone: true flag inside their @Component , @Directive or
@Pipe decorator.
info In this book we’re going to focus fully on the modern Angular
standalone approach!
Template context
Template context is a very useful mental model when working with
standalones. It boils down to the need to "collect" all the standalones that are
used in the template of any given component and make them available in the
imports: [ ] array of the component inside its @Component decorator…
Previously with the modules, this has been a bit more complicated because of
one additional level of indirection, the NgModule itself. In that case, the
template context was created on the level of the NgModule .
This meant that everything which was declared (declarables) or imported
(standalones or other NgModules) by the given NgModule was part of the
same template context and could be used in the templates of the
components indiscriminately.
29 Tomas Trajan
@tomastrajan
Theory
The NgModule based approach had the following tradeoffs:
In this book we’re going to focus fully on using standalones with their explicit
management of the template context with the help of imports: [ ] arrays.
Then, every single template context dependency is equal to the explicit
import on the Typescript file level which is equal to a line in a dependency
graph.
This parity and lack of indirection is great because it makes it much easier to
understand and reason about the impact of each such import. It will also help
us make the right decisions in regard to what should be the location of any
given standalone component, directive or a pipe from the architectural point
of view!
Learn more about the template context from this article where I take a deep
dive into how it is constructed for NgModules vs standalone components,
what problems it solves and how it evolved in Angular over time.
30 Tomas Trajan
@tomastrajan
Theory
Injectables
The second main group is the injectables. They represent building blocks like
services, route guards or interceptors which can be injected into each other
with the help of inject() (or older constructor-based injection).
In this book we’re going to use inject() based injection exclusively
because constructor-based injection nowadays only works fully with a
workaround in the tsconfig.json , where we have to set the
"useDefineForClassFields": false .
31 Tomas Trajan
@tomastrajan
Theory
Compared to the standalones with fully explicit references and self-
management of the template contexts, the injectables still work with one
level of indirection which are the injectors and the underlying injector
hierarchy. The injector hierarchy then determines which instance we’re going
to get when we chose to inject one service in another.
Injector hierarchy
In Angular, the injectables and the dependency injection system are
implemented with the concept of "injector hierarchy".
From the practical point of view, the injector hierarchy determines how many
instances of a given injectable (like service) will be created and which
instance will be injected into a specific consumer. For example, another
service or a component based on the closest injector within the hierarchy!
The hierarchy implies there will be multiple injectors organized in a
hierarchical tree structure that will map very well on top of the dependency
graph of our application and therefore also architecture.
Even though we’ve spoken little about it yet, in general there are two main
parts of each Angular application, eager and lazy loaded, and we’re going to
discuss it in depth very soon. For now, this simple distinction will be enough
to help us show some interesting properties of the injector hierarchy.
32 Tomas Trajan
@tomastrajan
Theory
Let’s zoom in on the Angular injectors in the eagerly loaded part of the
application. There are 3 main injectors here, two of them managed by Angular
behind the scenes. The third one, the root injector, is the one with which we
are going to interact directly by registering our injectables as providers!
All three injectors in the eager part of the application represent single eager
injector hierarchy, and the injectables registered in these injectors will be
application wide singletons or in other words, there will be only one instance
of any given injectable so whoever (including anything in lazy loaded
features) injects that injectable, it will get the same instance.
Let’s explore what happens when we start adding lazy loaded features and
with their own injectors…
33 Tomas Trajan
@tomastrajan
Theory
Adding a lazy-loaded feature creates its own isolated lazy injector, called an
EnvironmentInjector , in the injector hierarchy.
1. Lazy-loaded features can be fully isolated from each other and the eager
part of the application, this is one of the key benefits of the proposed
architecture, the injectables provided on the lazy loaded
EnvironmentInjector can’t be accessed from the sibling lazy loaded
features nor eager root injector!
2. Lazy-loaded features can access injectables which belong to eager
injector hierarchy which enables them to access injectables from the core
3. Providing the same injectable in multiple lazy-loaded injectors will result in
multiple instances of the given injectable (one per injector)
4. Providing the same injectable in both lazy and eager injector hierarchy will
result in multiple instances of the given injectable (one per injector)
34 Tomas Trajan
@tomastrajan
Theory
In practice, we’re going to provide our injectables in two ways:
Other injectors
There are other even more fine-grained injectors like ElementInjector
which is configured with the providers: [ ] array of a component or a
directive and allows us to create a unique instance of a given injectable for
each instance of component (or a directive) on which it was registered.
These solve other, more specific use cases and are therefore not as relevant
for the topic of overall Angular application architecture.
Function-based logic
Angular has recently added a new way of creating a couple of the useful
building blocks like route guards or HTTP interceptors without needing to
author a full-blown, class-based service with the @Injectable decorator.
35 Tomas Trajan
@tomastrajan
Theory
There are commonly called “function-based” guards (or interceptors). The
key benefit is that this approach makes it possible to author them in a more
light-weight fashion. For example, we could only author a function which
corresponds to a method of the original route guard class.
Such logic often needs to be composed with logic from other injectables like
services, for example function-based auth guard might need to inject
AuthService to retrieve auth state.
36 Tomas Trajan
@tomastrajan
Theory
Eager vs lazy part of the application
Previously, we have already mentioned lazy-loaded features and lazy bundles.
Now it’s time to bring these concepts into the spotlight and discuss how they
are implemented in Angular.
Eager part
Eager part of the application is the part that is loaded initially. This
corresponds in practice to the eager main.js bundle.
This might look slightly different when using esbuild which might
extract additional eager bundles (which are called chunks when
generated by the esbuild). But the most important thing stays the same,
all these eager bundles will be loaded in the beginning, at the Angular
application startup.
Lazy part
As for the lazy part, we have spoken a lot about the lazy-loaded features and
that their implementation will be located in the lazy bundles.
We also said that the bundler knows that it should extract a lazy bundle when
it encounters a dynamic import() statement.
37 Tomas Trajan
@tomastrajan
Theory
Routing based lazy loading
We’re going to use these dynamic imports explicitly in the Angular
application route configuration, most commonly as loadChildren: () =>
import('features/<feature-name>/<feature-name>.routes') in the
app.routes.ts .
info In code examples throughout this book, we’re going to use the
<some-placeholder> when we want to indicate that the
actual name of the feature or the placeholder will be different in
your application.
It would be similar for the lazy sub features which would be referenced with
the help of the loadChildren in the parent-feature.routes.ts file.
38 Tomas Trajan
@tomastrajan
Theory
Defer based lazy loading
Angular 17 introduced @defer as a great new way to lazy load individual
standalone components directly from the template of the parent
components with very little effort which is required on our side! Learn more…
1 @defer {
2 <my-org-heavy />
3 }
The impact of the @defer lazy loading syntax on the overall architecture is
minimal because we’re already implementing all of our components as
standalone and therefore, “defer ready”.
In general, we should only use the defer-based lazy loading to lazy load
components with large impact on the bundle size of any given lazy feature
with typical examples like:
These are usually implemented and consumed from the 3rd party libraries,
but the same approach would be recommended also for the large local
components.
But if it’s so easy to use, then why shouldn’t we lazy load every single
component?
39 Tomas Trajan
@tomastrajan
Theory
The best way to think about this is looking at the two extremes of the
spectrum. Let’s imagine a lazy loaded feature (page) with 10 components
which are nested in each other, e.g. container -> list -> item -> form ->
rich-editor -> controls -> dropdown -> option -> …
1. Every component is lazy loaded — we will need to load the previous one
before we know what to load next, which would lead to a waterfall of
requests! This is definitely suboptimal and leads to longer time to load the
whole page and worse UX!
2. Every component is eagerly loaded (added as a standard dependency in
the imports: [] of parent Angular standalone component) — this works
great until one of those components becomes excessively "heavy" (e.g.
chart, editor, …) In that case it will become beneficial to split that one
heavy component away from the main lazy loaded feature bundle to
prevent delaying of the whole feature!
info The @defer is a great new way to lazy load heavy components
directly from the template of the parent components, and it's
best used when lazy loading components like charts, rich text
editors, data tables, or maps which could be local to the current
application or come form a 3rd party library.
40 Tomas Trajan
@tomastrajan
Key concepts
Previously, we have set the stage by discussing all the things that will have an
impact on the shape of our architecture like the browser, the bundle and the
systems within Angular itself.
Now it’s time to explore some of the key concepts which are shaped by
the underlying base reality and will have a large impact on the resulting
architecture!
Lazy loading
As we’ve learned, the Angular application consists of the eager and lazy part
of the application.
These parts are separated based on which kind of JavaScript import (static or
dynamic) has been used when consuming the logic from those files. And at
the same time, that in Angular, the lazy part is implemented with the help of
routing (and defer).
The bundler then materializes this difference into a separate set of eager and
lazy JavaScript bundle files.
41 Tomas Trajan
@tomastrajan
Key concepts
Faster startup time for the users
Lazy loading of all features (and sub features) means that in the beginning
we’re only going to load the eager part (e.g. layout and some state logic
which needs to be there from the beginning) and the first lazy loaded feature
which the user is trying to access based on the current url.
This means that the user will need to download and execute a lot less
JavaScript bundle files compared to a situation with no lazy loading at all.
These savings will get more beneficial proportionally to the size of the
application. In an application with four roughly the same sized lazy features
and the eager part, user needs to download and execute ~40% of the overall
JavaScript to bootstrap the application and display the first feature.
In an application with nine lazy features, it becomes only ~20% and so on…
Downloading less JavaScript will always be faster than downloading one
large bundle which includes the whole application!
On the other hand, there is a counterargument that for many organizations,
the user base has access to great connection and sufficient bandwidth (e.g.
5G or even ethernet in the offices) and that the bundles are going to be
cached by the browser after the initial download negating the value and
hence questioning the investment into implementation of clean lazy loading
based architecture.
First, the bundles are only cached until there is a new release of the
application after which the users have to download new bundle files. This
combined with the common practice of continuous delivery practiced by
many organizations means that the bundle caching will provide only a limited
benefit in practice.
42 Tomas Trajan
@tomastrajan
Key concepts
But even if we assume that users have perfect connection and unlimited
bandwidth to fall back on, there is still a bigger issue that can only be
addressed by lazy loading.
Once the JavaScript bundle files are downloaded, they need to be parsed
and executed. This can take a non-trivial amount of time even on the most
performant desktop machines, and naturally, it gets even worse on mobile
devices.
This can be explicitly evaluated using the Chrome Dev Tools performance tab
with the “Start profiling and reload app” button. After that, we can search for
the “Evaluate Script” and “Evaluate Module” block in the flame chart.
The image above is taken from an application with clean architecture and
most of the code contained within the lazy-loaded features. Because of this,
the parsing and execution of JavaScript only takes a very short amount of
time.
43 Tomas Trajan
@tomastrajan
Key concepts
Faster developer feedback loop
Another big benefit of separating application logic into lazy loaded features
is the faster developer feedback loop.
The speed of the feedback loop can be measured as a time from saving a
change in the developer’s IDE to being able to see that change in action back
in the browser.
This feedback loop easily plays out thousands of times per day per developer.
It’s when they are working on a piece of code to fix a bug, add or extend a
feature, and as such, every saving translates into increased productivity and
less distraction.
The lazy loading of features and the corresponding separation of the eager
and lazy bundle files have a direct impact on the speed of this feedback loop.
The reason for that is that the dev process needs to rebuild and re-bundle
only the bundle file of the lazy feature that we’re currently working on!
In practice, it should be easy to imagine that rebuilding 87KB will be much
faster than rebuilding 3.2MB and it in fact is. With proper architecture and lazy
loading, working on a feature usually translates to a sub-second round trip (in
order of 100s of ms) while in an unstructured application each change can
easily climb to 3+ seconds per change!
44 Tomas Trajan
@tomastrajan
Key concepts
Isolation
Another huge benefit of properly implemented lazy loading of the features
and sub features is that it promotes (and to a degree enforces) isolation
between those parts of the code base.
We’re going to explore the benefits of isolation in its own chapter, here
we’re going to focus on how lazy loading promotes isolation.
This creates a first explicit line of defense against incorrect imports within
our codebase, e.g. consuming service from Feature A in a sibling Feature
B .
45 Tomas Trajan
@tomastrajan
Key concepts
Such implementation would fail at runtime when we would try to navigate to
the Feature B with the NullInjectorError: No provider for Service A!
error which would give a strong hint that we’re doing something we were not
supposed to!
This would play out the same if we tried to inject Service A1 registered in
the lazy Sub Feature A1 in the parent Feature A …
The following diagram illustrates these scenarios, both sibling and parent-
child, and how they would play out in practice.
46 Tomas Trajan
@tomastrajan
Key concepts
As a side note, if the service had to be used in both the Feature A and the
Feature B , we would have to extract it one level up, in this case the core/ ,
and register it as providedIn: 'root' . This fulfills the actual requirement
while preserving clean architecture and a one-way dependency graph, and
we’re going to discuss it at length later.
47 Tomas Trajan
@tomastrajan
Key concepts
Lazy loading heuristics
Now that we know about the benefits of lazy loading, and that the “unit of
lazy loading” is a lazy-loaded feature, let’s discuss some heuristics that can
help us determine what constitutes a lazy-loaded feature in our application!
Previously, we’ve learned that lazy loading of features usually happens on the
route level. This provides us with a hint that the most common collection of
use cases that can be extracted as a lazy loaded feature is a page!
48 Tomas Trajan
@tomastrajan
Key concepts
thumb_up Implementing all sub-views with help of the nested routing and
navigation makes it easy to add deep-linking capability which is
considered an UX best practice especially valued by the users!
49 Tomas Trajan
@tomastrajan
Key concepts
Isolation
Previously, we have already touched on the topic of isolation as one of the
main benefits of lazy loading features in our applications, but this only
scratched the surface.
The DRY is a principle aimed at reducing the code repetition in the codebase,
replacing duplicate code with abstractions. The idea is that each piece of
knowledge or logic should be represented in a single place in a system.
If the same information or logic is repeated in multiple places, any new
requirement that requires a change in how it should work will require us to
change the logic consistently in all these places. This can lead to errors and
inconsistencies…
50 Tomas Trajan
@tomastrajan
KeyAsconcepts
it turns out, in frontend applications, it’s 3 - 5x more valuable to have
more isolation than to focus on removing every instance of repetition
at the cost of increased coupling and introduction of additional
abstractions!
The isolation (which often goes hand in hand with slight code duplication) will
support these use cases in a much better way, and it will make their
implementation and maintenance much faster and less painful!
Now if we contrast this with a codebase which abstracts away parts of this
flow into a shared abstraction which couples these features and these
business flows together…
51 Tomas Trajan
@tomastrajan
Key concepts
In such cases, we often end up in a situation where the abstraction becomes
increasingly complex, in an attempt to handle all these edge cases in one
place which over time leads to unmaintainable “god” components and
services. We have unfortunately seen this play out in real life codebases far
too often!
Not at all! That’s why we say that isolation is roughly 3 - 5 times more valuable
than DRY, so we should definitely abstract away reusable components and
services, especially when they are generic UI widgets like a button or a
calendar, but we should think twice (or five times) before abstracting away
actual flow specific business logic!
52 Tomas Trajan
@tomastrajan
Key concepts
Isolation guarantees
With isolation in place, we get a couple of great guarantees which will greatly
improve our ability to maintain and extend the project over time.
Local impact
Changing implementation of a lazy feature can’t have any impact on any other
feature, core logic or shared UI widgets.
This also means we need to understand a much smaller fraction of the
codebase to be able to make the change with confidence. This also means
that the new team members will be productive and produce much better pull
requests, way earlier, than in codebases with less isolation and more
coupling.
Please keep in mind that the changes in the core, ui or pattern parts of
the code base will still have a wide-ranging impact and therefore a
reasonable test coverage is still required!
Local testing
In the same way, we are guaranteed that changing implementation of a given
feature will not break tests of any other feature.
53 Tomas Trajan
@tomastrajan
Key concepts
Isolation improves extensibility and evolution of code base
Focusing on isolation and the resulting isolation guarantees improves the
long term extensibility of the code base. It should be easy to imagine that
making changes in an isolated lazy loaded feature will be less work intensive
than in a “feature” which is tightly coupled to the rest of the codebase.
54 Tomas Trajan
@tomastrajan
Key concepts
As the Angular and frontend landscape evolves over time, we might want
to update our best practices without necessarily wanting to invest time
in migrating all of our existing code to these new best practices.
The reason for that is that these old features work as expected and such
effort is not really providing any business value and could be spent better
elsewhere.
Properly isolated architecture allows us to keep these older features as they
are while introducing new best practices in the new features that need to be
added at this point of time!
This also means that if some of the features are not completely up to the
desired quality standards, the impact of that “dirtiness” is again isolated and
therefore limited only to that feature!
55 Tomas Trajan
@tomastrajan
Key concepts
One way dependency graph
The one-way dependency graph is another key concept of the proposed
architecture.
As we have seen in the “Why does clean architecture matter?” chapter, the
madge-based analysis of the real life projects often uncovers very chaotic
dependency graphs. Such graphs often contain many cycles that make things
even worse as they become harder (or prohibitively expensive) to untangle
over time.
Following is a diagram of a very simple dependency graph which illustrates
the general “one way ness” the green arrows and what it would mean to add a
cycle (red arrow).
In such a graph, if we imagined the right most boxes are lazy features, the
direction of arrows means that it would be possible to remove each of the
features without affecting the rest of the application.
56 Tomas Trajan
@tomastrajan
Implementation hints
This book focuses on Angular architecture and does not prescribe one
specific way on how to implement actual features or manage state in our
applications.
This is not necessarily a bad thing as there are many different ways to manage
that and different teams will choose specific approaches and state
management libraries based on their needs and preferences.
It also helps to keep the content focused to solve one specific problem, the
architecture without unnecessary distractions. This is also underlined by the
fact that architecture can in fact be implemented independently of actual
business features and flows.
State vs view
(headless business logic vs view logic)
From experience, it’s usually a great idea to split logic into two main parts:
57 Tomas Trajan
@tomastrajan
Implementation hints
It is realistic and desirable for most components to be basically “logic free”
where the component would only get access to some state (e.g. from service
or NgRx selector ) and displays it in the template.
At the same time, any user interactions that need to be handled by the
component method will be immediately delegated to the service (or
dispatch an NgRx action).
1 @Component({
2 standalone: true,
3 selector: 'my-org-example',
4 })
5 export class ExampleComponent {
6
7 view = this.store.selectSignal(selectExampleView);
8
9 handleItemStatusChange(item: Item, status: Status) {
10 this.store.dispatch(updateItemStatus({ item, status }));
11 }
12
13 handleItemDelete(item: Item) {
14 this.store.dispatch(deleteItem({ item }));
15 }
16
17 // ...
18 }
59 Tomas Trajan
@tomastrajan
Implementation hints
What is rarely acknowledged is that the price for doing so is that we’re
introducing coupling between the parts of the codebase that could have
stayed fully independent and hence isolated!
In the long term, isolation between the unrelated parts of the codebase
provides project with much-needed flexibility to be able to evolve and
meet the ever-changing requirements which are so common in the
frontend!
thumb_up Waiting for at least 3 (or more) repetitions helps us see if the
abstraction is a good fit for a wider range of the use cases!
60 Tomas Trajan
@tomastrajan
Implementation hints
On the other hand, we should always refrain from abstracting away Angular
(or state management library) based code as that would introduce another
set of major downsides as we’re going to discuss in the next section…
warning We should always stay away from abstracting away Angular (or
state management library) based code. Trying to build our own
Angular+ will predictably lead to problems down the road!
61 Tomas Trajan
@tomastrajan
Implementation hints
Framework related
Base components, forms, directives, … - Angular then predictably evolves
in another direction than envisioned, which is not compatible with the
assumptions made in the custom logic, which leads to often very large
amount of effort to undo and rework this as the custom abstraction
“poisoned” the rest of the actual business logic
Custom abstraction can often lead to a situation which breaks automatic
migrations provided by the in form of ng update, a great example could be
something like *myOrgFor / *customIf and the Angular 17 migration to
new control flow
Custom testing and mocking-related abstractions (often also from 3rd
party libs) can lead to broken tests as we have seen with the advent of
standalone APIs
Various random bugs over time
Angular
State management library of choice
That’s it, staying on the level of Angular and a chosen state management
library represents the most responsible long-term choice for the project and
the organization.
62 Tomas Trajan
@tomastrajan
Implementation hints
How to handle “verbosity”
Not abstracting away everything will leave us at the default level of verbosity
of Angular and the chosen state management library. This is not necessarily a
bad thing as such verbosity tends to get better over time as Angular and the
selected state management library evolve together with the ecosystem
embracing new better patterns and practices.
For example, Angular 16 brought takeUntilDestroyed which is much more
concise than the older takeUntil(this.destroy$) pattern which required
the creation of additional Subject and use of the ngOnDestroy lifecycle
hook.
Another example is a steady decrease of verbosity in NgRx with every major
version bringing some improvements in that department. For example
creator APIs, action groups, functional effects or createFeature APIs.
As we have seen from real life experience, verbosity will decrease over
time “by itself” and the best thing about it is that such abstractions are
built upon feedback of tens of thousands of developers and will
therefore be much more likely of better quality than a home-grown
abstraction by a single team of developers.
63 Tomas Trajan
@tomastrajan
Implementation hints
Angular (NgRx, …) Schematics - generate building blocks like components,
services or whole state features in a consistent way following the latest
best practices together with a testing setup
Consistency in naming allows for using basic “search and replace”
functionality of our IDE, which can get us pretty far, especially when
employing a bit of regex “magic”
The ng-morph - an amazing and a bit advanced library that allows us to
implement code transformation scripts with ease, can be really worth it in
environments with lots of projects
Summary
Abstraction can be a useful and powerful tool when used correctly, in
practice we have seen it being overused and doing more harm than good,
hence the recommendation to prioritize isolation.
Every team and project is different and an abstraction, when used correctly
can also be a tool that increases the team delivery velocity. Just keep in mind
that such abstraction is more likely related to repetitive business flow than to
saving a couple of LOC in Angular or state management library boilerplate!
Your experience
Do you have an experience which would support or contradict the
recommendation to prioritize isolation over DRY?
Let me know @tomastrajan or drop me an email at [email protected].
64 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Now that we have built all the necessary theoretical understanding of
concepts, underlying realities, building blocks and approaches that will have
an impact on the proposed architecture, it’s time to start exploring the
architecture itself!
The first stop on this journey will be to go through all the building block
“types” from the perspective of the architecture and learn more about…
65 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
The big picture
The following diagram captures the essence of the proposed architecture.
Please make sure to pay extra attention to the eager / lazy boundary and
notice that all the building block types are present in this one single diagram
including core , layout , ui , pattern and feature !
66 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Types overview
Now it’s time to dive deeper into each of the types individually before we
bring them together into a real life application example!
The types also neatly fit into their own folders like core/ or feature/
which will make working with them a very intuitive experience once the
workspace was set up initially.
info Every type will have a quick summary in the beginning which will
make it easier to revisit and refresh the information later on.
67 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Core eager
The first type we’re going to discuss is the core which will live in the core/
folder of our application.
Core is the place to extract logic, if it needs to be used by more than one
feature , pattern or layout
The core is the right place for all logic that needs to be available from the
start like authentication state, current user or guards… Or in other words,
anything that is necessary to be able to display information in the layout
before loading of a specific lazy feature.
The second most common content of the core will be domain (or feature)
specific injector based (headless) logic that is shared by more than one lazy-
loaded feature.
68 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Standalone core
With the advent of standalone APIs, the core also transformed from
previously common CoreModule to provideCore() . This is the best location
for us to provide, define and setup, everything that should be available from
the start.
As such, provideCore() should be the sole location of this setup and the
initially generated app.config.ts should only contain the call to the
provideCore() itself. That way, there is no ambiguity or possibility to miss
something as everything is available in this one single place!
69 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Let’s discuss what is the typical content of the provideCore() that can be
found in the most Angular application. Remember that all these examples
always represent headless (injector) based logic, so no components,
directives or pipes as those will have their dedicated place elsewhere.
70 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
This will be performed with the help of the special
ENVIRONMENT_INITIALIZER provider which allows us to run any logic inside
its useValue() function. For example, when using NgRx, this could be a
place to dispatch an AppInit event which can then kickstart other
processing in the NgRx effects like resolving of the authentication token or
loading of the user.
We should always make sure that this logic is implemented as last within the
provideCore() otherwise we might get an error because we might be
injecting some providers which weren’t provided yet.
72 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Grouping core logic in folders per domain
In general, it is a good idea to group this logic in subfolders by domain, that
way, the previously discussed authentication state could be located in
core/auth/ or core/user/ or even both if that level of granularity makes
sense for that use case.
Similar to that, logic shared by more than one lazy feature, for example, NgRx
based order state slices which manages CRUD for the order entity, would be
stored in the core/order/ folder.
Other utils
The core is also the place to implement other simple function-based utils.
Typical examples could be functions to parse and transform format for things
like dates, query params, or anything else…
73 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Providers from NgModules
Even though we’re fully focused on the modern standalone approach and
APIs, we might still need to use some organization specific or 3rd party library
which only exports NgModules.
The provideCore() allows us to specify providers: [ ] , but to consume
NgModule we would need to be able to specify import: [ ] array which is
not supported.
The way to solve this problem is to use the importProvidersFrom() and pass
in the desired NgModule .
This helper function will then get access to all the providers of that module
while ignoring all the declarables honoring the distinction between two main
Angular systems, template context and injector hierarchy!
1 export function provideCore(options: CoreOptions) {
2 return [
3 // ...
4 importProvidersFrom(SomeChartsModule), // 3rd party library
5 importProvidersFrom(MyOrgAuthModule), // organization specific library
6 // ...
7 ];
8 }
Learn more about how to migrate existing NgModules into standalone APIs
from this article.
74 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Layout eager
With our core ready, our application can bootstrap and even kickstart some
processes, but it won’t display anything to the user just yet.
Typically, most applications will use one or more layouts as a “frame” around
the slot in which we’re going to display specific lazy loaded features based on
user navigation or interaction.
In case of single main layout which is used for the whole app it will be used
directly in the template of the AppComponent
75 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
In the case of multiple layouts, it will be used as part of the route config
Applications can have more than one layout, e.g. login, signup,
authenticated
Some application may need a custom layout per feature and in that case
the layout/ folder may end up empty, the AppComponent will then end
up with a template consisting only of single <router-outlet /> and each
feature will implement its own layout within its own feature folder
76 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Multiple layouts (e.g. login and main layouts)
Another common scenario is when an application has a relatively low number
of layouts, e.g. one for authentication purposes and then another once to
display every other lazy feature once a user has logged in.
This is best solved as a part of the application routes config in the
app.routes.ts and the AppComponent will contain just a lone <router-
outlet /> in its template.
The gist of the solution is to have two main configs with two main contexts,
e.g. '' and '/app' which will use respective layout as its component and
then define lazy loaded features as their children.
1 export const routes: Routes = [
2 {
3 path: '',
4 component: AuthLayoutComponent, // <--- login, signup, ... layout
5 children: [
6 {
7 path: 'login',
8 loadChildren: () => import('./feature/login/login.routes')
9 }
10 // signup feature, password recovery feature ...
11 ],
12 },
13
14 {
15 path: 'app',
16 component: MainLayoutComponent, // <--- main layout
17 children: [
18 {
19 path: 'orders',
20 loadChildren: () => import('./feature/orders/orders.routes')
21 }
22 // dashboard, profile, settings feature ...
23 ],
24 }
25 ]
77 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Custom layout per feature
Another option is that the features are so vastly different that they need to be
able to define custom layouts, one per each feature.
In that case, the AppComponent will again contain just the
<router-outlet /> , the layout/ folder will be empty and each feature will
define its own layout as a part of the implementation of that feature, for
example feature/dashboard/layout/ .
In such a case, the app level routes config will be very simple and will only
define lazy loaded features.
1 export const routes: Routes = [
2 {
3 path: 'orders',
4 loadChildren: () => import('./feature/orders/orders.routes')
5 },
6 // dashboard, profile, settings feature ...
7 ]
Each feature will then define its own layout as a part of its own routes’ config.
1 export const routes: Routes = [
2 {
3 path: '',
4 component: DashboardLayoutComponent, // <--- custom (per feature) layout
5 children: [
6 {
7 path: '',
8 component: DashboardComponent,
9 },
10 // other dashboard related routes...
11 // other dashboard related sub lazy features...
12 ],
13 },
14 ];
Of course, it’s also possible to express our layouts as a combination of all the
approaches mentioned above.
78 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
UI eager lazy
Now it’s time to talk about the UI type which is going to live in the ui/
folder. The improved spiritual successor of the SharedModule , this type is
special in that it’s basically the only type that will in practice be consumed by
both the eager and lazy parts of our application.
79 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Standalones only
The UI as a type and therefore ui/ as the folder should only ever contain
standalones (components, directives and pipes) or in other words, only the
building blocks which are related to the template context system of Angular.
Specifically, it’s the fact that every single standalone component needs to
build its own template context. This allows us to cherry-pick only what is
used and therefore also get the most optimized bundles, which also means
the smallest possible bundle size.
This is also why we can use these generic UI components, directives and
pipes also within the eagerly loaded part of the application, namely the
layouts without any detriment!
The consequence of this approach is that the ui/ should never contain
implementation of any services or other headless logic and at the same time
these components should never really import it from the core either.
As an example, if we ever felt that a component from UI needs to import and
inject some service, then this should most likely happen in the parent
component instead (e.g. in layout or feature or pattern). Such component
can then retrieve such a state from the service and pass it down to the UI
component using the input binding.
80 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Generic reusable UI components
The simplest way to convey what is meant by the generic UI component is to
imagine something that is commonly provided by the 3rd party component
libraries like Angular Material, PrimeNg or TaigaUI. Components like card ,
button or menu and directives like drag , drop or focusTrap .
Most applications will use some kind of component library, be it a 3rd party
open source or internal library of the organization, the chances are high that
the application will not need to implement the whole base suite of UI
components from scratch.
On the other hand, it’s also pretty typical that the application will need to
implement a couple of additional generic UI components which are re-used
across multiple lazy features.
The key feature of the generic UI components is that they never consume any
data directly from injected services or from a state management mechanism
like selectors. Instead, they will delegate this responsibility to the parent
component and receive data through inputs and notify parent about the
change through the outputs.
This constraint makes them universally reusable in every feature, pattern or
layout of the application without any need to introduce various conditions,
workarounds, and therefore coupling.
81 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Eager / lazy bundling implications
The standalone nature of the UI allows us to consume it in both eager and lazy
parts of the application without negative consequences for the initial bundle
size. The reason for that is that each standalone is consumed explicitly in a
“cherry-picking fashion”.
But what are the actual implications and how will this play out with bundler in
the real life application?
Let’s illustrate this using the following couple of examples which will run us
through various scenarios of how an example generic UI component, the
<avatar /> (which displays user avatar), could be used in an application.
1. Eager only - Component is only used in main layout to display the avatar of
the currently logged-in user and nowhere else. Therefore, it will be
bundled in the eager main.js bundle. In such a case, it would also make
sense to consider moving its implementation from ui/ to layout/
2. Eager and lazy - Component is used in main layout to display the avatar of
the currently logged-in user and also in features like tasks, orders, and
projects where it displays user avatar for the user which is responsible for
handling the respective task, order or project. The avatar is therefore used
in both eager part (layout) and multiple lazy features (task, order, project)
and will be bundled in the eager main.js bundle.
82 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
3. Multiple lazy - Component is used in multiple features like tasks, orders,
and projects where it displays user avatar for the user which is responsible
for handling the respective task, order or project. The component will be
extracted into a “virtual pre-bundle” by the bundler and will be then
loaded together with any of the lazy loaded features in which it was used
when the user navigates to any one of them. Navigating to another lazy
feature which uses it after it was already loaded will simply use it as it’s
already available.
4. Single lazy - Component is used in single lazy features like tasks where it
displays user avatar for the user which is responsible for handling the
respective task. In this case, the bundler will bundle this component in the
bundle of that lazy-loaded feature. In this case, it would also make sense to
consider moving its implementation from ui/ to feature/task/avatar/
and only extract it back to ui/ once there is a need to reuse it another
lazy feature, pattern or layout!
The behaviors and resulting bundling described above will depend and may
differ based on the actual
internal configuration of Angular CLI for webpack and esbuild, but in general,
the principle holds true. Even though we spoke about components, the same
behavior and rules apply to other standalones, the directives and pipes!
Each of the ui standalones will be consumed explicitly in each feature,
pattern or layout, specifically in their components and templates…
1 @Component({
2 selector: 'my-org-header',
3 imports: [AvatarComponent], // ui standalone added to the template context
4 template: `
5 <img class="mx-auto" src="..." alt="MyOrgLogo" />
6 <my-org-avatar [user]=user /> <!-- ui standalone used in the template -->
7 <!-- ... -->`,
8 })
9 export class HeaderComponent {}
83 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
How to handle types and interfaces
Let’s start with a description of one of the common challenges that will arise
in most of the projects as a result of the following set of constraints:
1. UI standalones receive all the data through inputs and notify parent about
changes through outputs
2. These inputs and outputs need corresponding types and interfaces to
make them type safe
3. Data with its corresponding interfaces and types will be managed and
provided by services or state management solution which are
implemented in core or in the respective feature
4. UI standalones can’t have direct dependency on (or import from) the core
or any lazy feature
For example, the UI standalone <avatar [user]="user" /> displays
user image, name and role. These properties are also available in the
User interface which is used to type user entity which is managed by
the UserService in the core. What would be the best way to handle this
situation to fulfill all the constraints?
There are a couple of ways to handle this situation, each with its own
individual set of tradeoffs. Let’s go through them one by one…
84 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Rework ui standalone into a pattern
The first option is to implement the avatar as a pattern, which is a more
advanced architectural type discussed in later chapters. The pattern could
then import the User interface as it is allowed to import form the core. The
downside of this approach is that the avatar would then be able to import any
other service or logic from the core, and especially, the avatar is not really a
pattern but a generic UI component.
Define a new architecture type (eg model) just for types and interfaces
With this approach, we would define a new type, eg model which would then
contain source files which implement all the types and interfaces. The newly
defined model type would have a very permissive set of architectural
constraints which would allow all other types to import from it without any
restrictions. Such a solution can be successfully employed in many projects.
The downside is that now we have to maintain similar types and interfaces in
multiple places, and possibly introduce some kind of mapping between the
User entity and the Avatar entity in the future as they diverge.
Such an approach can work very well in simpler cases with a rather small
number of properties. At the same time, the issue of diverging User
interface can be solved by a simple mapping directly in the template of the
consumer component which is using the <avatar /> component.
1 <!-- original -->
2 <my-org-ui-avatar [imageUrl]="user.profileImageUrl" />
3
4 <!-- updated, can be easily adjusted when the User entity changes -->
5 <my-org-ui-avatar [imageUrl]="user.resource.imageUrl" />
86 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
How to handle complex pipes
Another common challenge is related to implementation of pipes in the UI
architecture type and happens in situations which can be described as
follows:
thumb_up It's always a good practice to separate pipe into a thin "wrapper-
like" pipe, which will be used in the template, and a service which
will contain the actual logic. That way, the logic will be available
to both components (can be used in the template) and services
(can be used in the business logic).
This would turn our pipe into a pattern which is a more advanced architectural
type discussed in later chapters as patterns can import from the core and
therefore can contain services and other logic.
This is not an issue and more complicated pipes can in fact be implemented
as patterns!
87 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
The best way to solve this issue is to remove the need to use the pipe inside
the UI component altogether. This can be achieved by using the pipe in the
parent component instead (eg layout, feature or pattern) and pass the result
down to the UI component…
This usually comes in one of two forms:
1. options driven APIs — the ui component receives the data and options
through inputs, we’re using the pipe in the parent component to transform
the data before it’s passed down to the ui component
1 <my-org-ui-price [price]="product.price | myOrgPatternPricePipe" />
In both cases, the need to use (and import) the pipe in the UI component is
removed. The pipe is implemented as a pattern and the clean architecture is
preserved!
88 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Feature lazy
With all the reusable types and building blocks in place, it’s time to start
implementing actual use cases and business flows that will be used directly
by our users!
89 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Isolation and the “black box” or “throw-away” nature of lazy features
Through the previous chapters, we have learned that isolation between lazy
features (features can’t import from one another) is one of the key properties
of this architecture.
This means that each lazy feature can be treated as a “black box” which
can contain any kind of implementation, building blocks and even lazy
sub features.
90 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Nested lazy sub features
In practice, it’s also pretty common that some features will be so large that it
will make sense to split them into additional lazy sub features. This goes often
hand in hand with additional level of navigation in the form of a submenu
commonly located in the sidebar.
91 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
The implementation of these features then should be located within the
parent feature folder, for example feature/order/dashboard/ or
feature/order/definitions/ .
Such a lazy sub feature is the lazy loaded based on the config located in the
parent feature routes config, first let’s explore the nested children lazy sub
features scenario…
1 export const routes: Routes = [
2 {
3 path: '',
4 component: OrderComponent, // <--- parent lazy feature component
5 children: [ // will always be displayed, needs <router-outlet /> ...
6 {
7 // will be displayed when navigated to and will be added to the parent view
8 path: 'dashboard',
9 loadChildren: () => import('./dashboard/dashboard.routes'),
10 },
11 {
12 path: 'definitions',
13 loadChildren: () => import('./definitions/definitions.routes'),
14 },
15 ],
16 },
17 ];
Another option is that lazy sub features replace the parent view when
navigated to…
1 export const routes: Routes = [
2 {
3 path: '',
4 component: OrderListComponent, // <--- parent lazy feature component
5 },
6 {
7 // will be displayed when navigated to and will REPLACE the parent view
8 path: 'dashboard',
9 loadChildren: () => import('./dashboard/dashboard.routes'),
10 },
11 {
12 path: 'definitions',
13 loadChildren: () => import('./definitions/definitions.routes'),
14 },
15 ];
92 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Fractal nature of the lazy sub features
In previous example, we have added one additional level of the lazy features
and corresponding navigation, but there is nothing stopping us from adding
even more levels of lazy sub features and navigation.
This is a great example of what we can call a "fractal nature" of the lazy
features and navigation! It will allow us to add as many levels of lazy sub
features and navigation as we need while following exactly the same pattern
in their implementation!
93 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
How to share logic between multiple lazy features
Throughout this book, we have learned and discussed that the isolation
between parts of the application and especially between the lazy features
plays a key role in the architecture.
But the real world use cases are not always so clear-cut to neatly fit our
idealized architecture, and it is often useful and therefore necessary to be
able to share some logic between multiple lazy features.
Now is the time to talk about what are the most common types of logic
that we might want to share between the lazy features. Let’s learn how to
handle each of these cases in a way which preserves the isolation, clean
one way dependency graph and therefore all the benefits of the
architecture itself.
info Sharing logic between lazy features can be summarized with the
“extract one level up rule”, but where exactly the “one level up”
is, depends strongly on the type of logic that we want to share.
95 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
The main distinction in the sharing behavior depends on the level of the lazy
feature, the first level lazy features will extract shared logic to the core/ or
ui/ folder, while the nested lazy sub features will extract shared logic to the
parent lazy feature.
used in more than one lazy feature on the first level, will be extracted to
the ui/ folder
used in more than one lazy sub feature of the same parent lazy feature
will be extracted to feature/parent-feature/ folder
core - injectables, services and other headless logic like state
management library state slices or utils, typical examples could be
OrderService , OrderState (NgRx based state slice), OrderUtils
used in more than one lazy feature on the first level, will be extracted to
the core/ folder
used in more than one lazy sub feature of the same parent lazy feature
will be extracted to feature/parent-feature/ folder
thumb_up When extracting logic with multiple related files to the core/
folder, you can always use domain-based grouping, e.g.
core/product/ or core/order/ .
96 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
The last type of sharing between lazy features is the pattern . The main
distinction between pattern and the other two types is that the pattern is
a pre-packaged combination of standalones and injectables which is focused
on implementing of a specific use case.
This makes it categorically less generic and reusable than the other two
types, but it also means that it can be used to share more complex logic
between lazy features.
97 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Besides the differences, the pattern also follows the same extraction rule
as the other two types, which should prove intuitive and easy to remember.
used in more than one lazy feature on the first level, will be extracted to
the pattern/ folder
used in more than one lazy sub feature of the same parent lazy feature
will be extracted to feature/parent-feature/ folder
98 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
For example, in an application which allows us to define and manage various
types of ad-hoc checks that we can execute against the source code
repositories in our organization and collect the results… The application will
support multiple types of checks, and we will most likely want to display
them in a nice stylized way implemented in the CheckTypeComponent .
Then, the way this can unfold could be described as follows:
1. We’ve started working on the application, and the first lazy feature we’re
working allows us to display and manage the list of checks, we will call it
feature/checks/
5. With the checks feature implemented, we’re moving to the next feature
which allows us to display results of the checks per analyzed repository, we
will call it feature/results/
6. We realize that we need to display the same stylized check in the
feature/results/ feature as well
99 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Sharing NgRx state slices between lazy features
Another common example is the need to share some piece of the state
between multiple lazy features.
Here we’re going to assume that the application is using NgRx as the
state management library of choice. At the same time, it would play out
in more or less the same way with other libraries, or something like
services with RxJs subjects or the newest kid on the block, the Angular
Signals.
With the stage set like this, let’s explore how the sharing of the CheckState
in the form of the NgRx state slice could play out in practice…
info The partial NgRx state slice sharing and extraction represents a
rather advanced example of what is possible to achieve when
following this architecture with relative ease. It should be easy to
imagine that the overall process of sharing injectables will be
much simpler and more akin to the sharing of the UI components
when we would need to extract something like a
CheckService instead of the NgRx state slice.
lazy sub feature - located inside the same folder as the parent lazy feature,
has less strict isolation and falls under the "black box" approach
shared feature - located in the separate folder, has complete isolation of
its implementation and can only be referenced through its route config
That way, once the new requirements arrive, we will be able to extend our
application in a predictable standardized way which can scale further
into the future!
The last main architecture type that we need to discuss is the pattern which
is a type that we have mentioned briefly in the previous chapter about the
features but haven’t really explored in depth yet.
Patterns are usually consumed through their main "drop in" component which
is used in the templates of lazy features…
1 <!-- feature/order/order.component.html -->
2 <my-org-order-list />
3 <!-- other feature specific components... -->
4
5 <my-org-document-manager /> <!-- pattern consumed through the "drop in" component -->
The idea for the name came from the development process of a generic
organization specific UI library (think in direction of MyOrgMaterial)
where we wanted to differentiate between the level of individual
generic reusable UI components (e.g. button or popover) and the level
of pre-packaged combination of such components, e.g. "context
menu" (a multiple buttons in a popover) which we called patterns …
Another great name which includes information about the way most of the
patterns will be used is the "drop in" feature. In general, each pattern will have
one root component which can be dropped in anywhere in the template of a
component which belongs to a lazy-loaded feature.
From this perspective, a pattern can be also described as a "non-routed"
feature.
thumb_up Even though patterns aren't used with the help of Angular
routing, we can still lazy load them with ease with the help of the
@defer blocks, but it is always important to consider the
tradeoffs and only opt in for such an approach if patterns is heavy
(large bundle size) or is only used after some specific user
interaction performed in the consumer lazy feature.
The scenario will deal with an application which manages products and
orders and also needs to store and display various types of documents,
for example, user guides, product specifications, order confirmations
and invoices. This example also assumes that we don’t necessarily see
the final picture from the start and that the requirements evolve over
time, and we have to react to them.
1. Quite different behavior and UI - In this case, it would make sense to keep
the implementation fully isolated to preserve the ability to evolve it
independently in the future. The cost of that will be a slight duplication, in
our case, the order feature will implement its own (and much simpler)
version of the document service and related components.
2. Similar behavior but different UI - In this case, it would make sense to
extract the common behavior into the core/ for example
core/document/ folder and implement the UI separately in each feature.
The cost of that will be a slight duplication, in our case, the order feature
will implement its own (and much simpler) version of the related
components.
3. Different behavior and similar UI - In this case, it would make sense to
extract the common UI into the ui/ for example ui/document/ folder
and implement the behavior separately in each feature in form of a
document service or event a state management solution for the more
complex one. The cost of that will be a slight duplication, in our case, the
order feature will implement its own (and much simpler) version of the
document service.
4. Similar behavior and similar UI - In this case, it would make sense to
extract the common behavior and UI into the pattern/ for example
pattern/document-manager/ folder and implement the integration in
each feature in form of a "drop in" component. This will effectively
eliminate any duplication but will couple the pattern implementation to
different requirements of the features which use it which will lead to need
to parameterize the pattern and therefore make it more complex.
The characteristic feature of all these patterns is that they either work
the same in every consumer or receive a relatively small amount of
configuration from the consumer lazy feature to work in a slightly
different way.
Document manager
Load, display and manage documents, e.g. user guides, product
specifications, order confirmations and invoices…
Approval process
Load, display and manage approval process, e.g. approval of the order or
approval of the invoice…
Notes / comments
Load, display and manage notes / comments, e.g. notes / comments for
products or interaction with specific customer…
The rules set, which we will focus on and implement soon, will be
responsible to ensure automated validation of our architecture!
It will work in a way that will throw an error if it discovers a file that does
not belong to a predefined type or wasn’t defined as ignored.
This is the reason why we have to define a type for all files in our application
including the app.component.ts , app.config.ts , app.routes.ts and the
main.ts , even though their implementation will stay almost as empty as
when they were first generated with the help of Angular Schematics when fist
creating the workspace with the help of ng new .
info Another option would have been to add all these files to the
ignore list, but because these files still import and consume
our implementation, for example app.config.ts will always
import and use the core.ts file, we want to make sure that
they are accounted for in the architecture as well.
The app type will consist of all files starting with app. prefix, for example
app.component.ts ( app.component.spec.ts ), app.config.ts and
app.routes.ts .
These files will mostly stay as they were generated with the help of Angular
Schematics when first creating the workspace with the help of ng new .
Main eager
The main type will consist only of the main.ts file which is used to bootstrap
the application. The file itself will stay as it was generated with the help of
Angular Schematics when first creating the workspace.
1 import { bootstrapApplication } from '@angular/platform-browser';
2 import { appConfig } from './app/app.config';
3 import { AppComponent } from './app/app.component';
4
5 bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));
The content of the file might need to be adjusted in the near future Q1 2024
to enable "zone-less" change detection scheduler for even better
performance, but that’s a topic that is out of scope of this book.
116 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
Ignoring other infrastructure files
Besides the app and main files, our workspace may also contain other
TypeScript files which are not part of the application implementation itself,
but are used to configure infrastructure related tools like testing or linting,
for example karma.conf.ts or jest.setup.ts .
We don’t really need a type (and corresponding rules) for these files as
they won’t really ever be imported in the files with our implementation,
not even by accident!
Soon we’re going to learn more about how we can define architecture
validation rules, but let’s add the ignore, snippet also here to make sure that
it’s easily accessible when needed.
1 // .eslintrc.json
2 {
3 "overrides": [
4 {
5 "files": [
6 "*.ts"
7 ],
8 // ...
9 "settings": {
10 "boundaries/ignore": [
11 "**/jest(.|-)*.ts",
12 // ...
13 ]
14 }
15 }
16 ]
17 }
But how do these types relate to each other? What are the rules which
govern their relationships?
In fact, we have already mentioned some of the rules and restrictions which
will govern the relationships between these types! Now it’s time to explore
them in more depth, all together, in one place!
And there is no better way to get started than with a diagram which visualizes
the big picture…
The diagram above contains all types which we have seen previously, but now
they are arranged in a more structured way which highlights their
relationships in regard to the eager and lazy parts of the application.
info Notice and remember that both ui and pattern types can be
consumed by the eager part of the application, namely the
standalones in the layout . This will be more common for the
ui type, but rarely it can also be a case for the pattern type.
warning Always keep in mind that there should never be any dependency
arrows between the sibling lazy features, sibling lazy sub features
or between individual patterns!
thumb_down The core/ (or for that matter any other eager part of
application) depending on a feature is especially problematic
as it breaks lazy loading and the underlying bundling which has
direct measurable negative consequence for the users (startup
performance) and developers (rebuild time on every change).
The plugin can support everything from basic single src/ based workspace
to a more advanced setup with multiple Angular applications and libraries in
the projects/ folder of the standard Angular CLI workspace.
basic setup - Install dependencies, add the plugin itself, extend base
rule set, setup Typescript import resolver, …
types definitions - Define settings to recognize types, e.g. feature or
core based on their location within the workspace
rules definitions- Define rules to govern relationships between the
previously defined types, e.g. feature can import from core but not the
other way around
Basic setup
To use plugin, we first have to install a couple of dependencies. Assuming
that the current Angular CLI workspace already has eslint support which was
added during the first execution of ng lint , we still have to install…
1 npm i -D eslint-plugin-boundaries eslint-import-resolver-typescript
Once the dependencies were installed successfully, it’s time to have a look
into the root .eslintrc.json file of the workspace.
info The boundaries/strict rule set is the way to go for all new
projects, but it's also possible to start with the less strict
boundaries/recommended rule set which is more suitable for
existing projects as it allows us to have parts of the project non-
compliant with the defined types, making it possible to refactor
the codebase progressively.
With the base setup in place, it’s time to define the types and rules which will
govern their relationships…
In the following example we’re going to highlight the difference between the
definitions for the src/ based workspace and the projects/ based
workspace and after that we’re going to proceed with the full configuration
example for the projects/ based workspace as that is the focus of this
book and what is used in the provided example repository.
1 {
2 "overrides": [
3 {
4 "files": ["*.ts"],
5 "settings": {
6 "boundaries/elements": [
7 {
8 // "projects/" based workspace
9 "type": "feature",
10 "pattern": "feature/*",
11 "capture": ["feature"],
12 "basePattern": "projects/**/src/app",
13 "baseCapture": ["app"],
14
15 // "src/" based workspace
16 "type": "feature",
17 "pattern": "src/app/feature/*",
18 "capture": ["feature"],
19 }
20 ],
21 }
22 }
23 ]
24 }
pattern - pattern to match the files which belong to the specific type
capture - capture groups to extract the name of the type from the
pattern, e.g. features/order will capture order , captures *
basePattern - pattern to match a path from the root of the workspace
to a specific app
baseCapture - capture groups to extract the name of the app from the
base pattern, e.g. projects/my-org-app/src/app will capture my-org-
app , it captures what is matched by **
pattern - pattern to match the files which belong to the specific type
from the root of the workspace
capture - capture groups to extract the name of the type from the
pattern, e.g. src/app/feature/order will capture order , captures *
The final types definitions for the projects/ based workspace will be
provided in the next code example. The example is rather long, so it will
be split into multiple pages but in the actual .eslint.json file, it will be
a single block of the "boundaries/elements" definitions…
Notice that the value of type and pattern property in the next code
example matches the name and folder of the architecture type which we
have learned about in the previous chapters.
warning The defined rules come with some limitations, for example, we
should not use app- prefix for a file within feature or ui types as
such file would get matched by and assigned to the app type
which is undesirable!
The reason for this is that we're trying to optimize the tradeoff
between the security provided by the strictness of the
architecture and ability to get things done in a reasonable
amount of time!
Rules definitions
With the types definitions in place, it’s time to focus on the last missing piece
of the puzzle, the rules definitions which will govern the relationships
between the previously defined types!
The rules themselves will be defined in the same .eslintrc.json file of the
workspace in the rules section where we’re going to specify them as the
part of the "boundaries/element-types" config.
As with the type definitions there will be a slight difference between rules for
the src/ and the projects/ based workspace.
1 {
2 "overrides": [
3 {
4 "files": ["*.ts"],
5 "rules": {
6 "boundaries/element-types": [
7 "error", {
8 // without explicit rule, depending on (importing from) files is disallowed
9 "default": "disallow",
10 "rules": [
11 // "projects/" based workspace
12 {
13 "from": "core",
14 "allow": [ // the core can depend on itself (within a single app)
15 ["core", { "app": "${from.app}" }],
16 ["lib-api"] // core can depend on the public API of a library
17 ]
18 },
19 // "src/" based workspace
20 {
21 "from": "core",
22 "allow": ["core"] // the core can only depend on itself
23 }
24 ]
25 }
26 ]
27 }
28 }
29 ]
30 }
As we can see the projects/ type workspace rules are more complex
because they have to account for the fact that there can be multiple apps
and libraries in the single Angular CLI workspace.
133 Tomas Trajan
@tomastrajan
Angular Enterprise Architecture
This extra complexity, ["core", { "app": "${from.app}" }] instead of just
"core" , allows us to enforce the type depending on other types within the
same application and an explicit rule for consumption of libraries through
their public API ["lib-api"] is also necessary.
In the similar way as previously, rules definitions will be split into multiple
pages but in the actual .eslint.json file, it will be a single block of the
"boundaries/element-types" definitions…
1 {
2 "overrides": [
3 {
4 "files": ["*.ts"],
5 "rules": {
6 "boundaries/element-types": [
7 "error", {
8 "default": "disallow",
9 "rules": [
10 {
11 "from": "main",
12 "allow": [["app", { "app": "${from.app}" }]]
13 },
14 {
15 "from": "core",
16 "allow": [["lib-api"], ["core", { "app": "${from.app}" }]]
17 },
18 {
19 "from": "ui",
20 "allow": [["lib-api"], ["ui", { "app": "${from.app}" }]]
21 },
22 {
23 "from": "layout",
24 "allow": [
25 ["lib-api"],
26 ["core", { "app": "${from.app}" }],
27 ["ui", { "app": "${from.app}" }],
28 ["pattern", { "app": "${from.app}" }]
29 ]
30 },
31 ]
32 }
33 ]
34 }
35 }
36 ]
37 }
This tends to work really well for the vast majority of Angular projects,
but it’s reasonable to acknowledge that there might be some projects
which have specific needs which are not covered by proposed global
setup.
The key idea to realize that it’s all about the isolation and that the isolation
can be achieved on any technological level!
And then there are technical limitations of the tooling, which may result in
very slow builds, testing or linting that lead to painful friction in the
development process!
… but the architecture itself and the underlying principles are more or less the
same and should be applicable, even if in an adjusted form, to the vast
majority of Angular projects!
Ideal fit
The approach in this book is ideal for the new projects in a standard Angular
CLI workspace. Such a workspace can easily support moderate number of
applications and libraries which can be worked on by multiple teams in
parallel because of the strong focus on the isolation and the clear boundaries!
info The provided example repo can serve as a great starting point,
but it can be still very useful to go through the process of
creating the architecture from scratch to understand the
concepts and ideas in more depth.
This should empower us to be able to adjust the architecture to
the specific needs of the organization as well as to get a better
idea how such architecture can be back-ported to the existing
projects in your organization.
Setup environment
First, we have to make sure that we are using a supported version of Node.js
and NPM which can be determined by checking actively supported versions
on the official Angular.dev documentation.
Once we have the correct version of Node.js and NPM installed, it’s time to
install the latest version of Angular CLI…
1 npm install -g @angular/cli@latest
which should output the version of the Angular CLI and the underlying
Angular framework (if run in an existing Angular workspace)…
1
2 _ _ ____ _ ___
3 / \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
4 / \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
5 / ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
6 /_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
7 |___/
8
9
10 Angular CLI: 17.1.2
11 Node: 20.9.0
12 Package Manager: npm 10.1.0
13 OS: linux x64
14
15 Angular:
16 ...
17
18 Package Version
19 ------------------------------------------------------
20 @angular-devkit/architect 0.1701.2 (cli-only)
21 @angular-devkit/core 17.1.2 (cli-only)
22 @angular-devkit/schematics 17.1.2 (cli-only)
23 @schematics/angular 17.1.2 (cli-only)
info The flags may differ in the future. It is always possible to list
supported flags with ng g application --help and the --
help flag can be used with any command to get more info!
and then create a .prettierrc file in the root of the workspace with the
following content…
1 {
2 "singleQuote": true
3 }
It’s usually a good idea to bind Prettier to a specific key combination in the
IDE to be able to format the whole file (or selected code block) on demand.
147 Tomas Trajan
@tomastrajan
Hands on architecture
With Prettier in place and configured for the used IDE, let’s also add
formatting related scripts to the package.json to make it possible to
format or check if it’s properly formatted in whole workspace!
1 {
2 "scripts": {
3 "format:test": "prettier --list-different \"./projects/**/*.{ts,html,scss,json}\"",
4 "format:write": "prettier --write \"./projects/**/*.{ts,html,scss,json}\""
5 }
6 }
Let’s test the formatting by running the format:test script in the terminal
and out of the box there are going to be some files that are not properly
formatted…
1 npm run format:test
2
3 > [email protected] format:test
4 > prettier --list-different "./projects/**/*.{ts,html,scss,json}"
5
6 projects/example-app/src/app/app.component.html
7 projects/example-app/src/app/app.component.spec.ts
8 projects/example-app/src/app/app.component.ts
9 projects/example-app/src/app/app.config.ts
10 projects/example-app/src/index.html
11 projects/example-app/src/main.ts
12 projects/example-app/tsconfig.app.json
13 projects/example-app/tsconfig.spec.json
Follow up, run of the format:test script should not output anything, which
means that the whole workspace is properly formatted!
The formatting setup can be further enhanced by its integration with the
version control system in form of the commit hooks, for example with lint-
staged or husky , learn more…
The use of the automated architecture validation will help us to keep the
bundle size under control, but at the same time, it still can be very useful
to have a tool in place which will allow us to analyze and get the full
understanding of where our application stands when the need arises.
There are three main tools that can be used to analyze the bundle size…
The -o and -c-1 flags for http-server are used to automatically open the
browser and disable the cache respectively to make sure we’re always seeing
the latest result.
As such, we can think about the budgets as a unit test for the bundle size
and a great way to enforce the desired bundle size limits!
The budgets are specified in the angular.json file per application in the
configurations section…
1 {
2 "projects": {
3 "example-app": { // app name (will be configured per app)...
4 "architect": {
5 "build": {
6 "configurations": {
7 "production": {
8 "budgets": [
9 {
10 "type": "initial",
11 "maximumWarning": "750kb", // reasonable initial targets
12 "maximumError": "1mb" // depends on used libraries
13 } // and the amount of eager impl
14 ]
15 }
16 }
17 }
18 }
19 }
20 }
21 }
Once done, let’s create a deps/example-app/ folder, after that we are going
to add new analyze:deps scripts to the package.json …
info Notice that we're namespacing the output file path with the
name of the application to make sure that it will be easy to
extend the script to analyze multiple applications in the future!
Once more apps are added, we would also namespace the script
itself, for example analyze:deps:example-app
It might be a good idea to re-run this script at some time interval (e.g.
once per month) and especially when merging large PRs to make sure
that we’re not introducing any circular dependencies! We can easily
recognize them as they are highlighted in red in the generated graph!
Currently, it is not added as a part of the Angular CLI workspace out of the
box, but it’s very easy to add it afterward with the help of Angular
schematics…
1 ng lint
Running this will detect that there is no eslint support in the workspace
yet, and will offer to add it…
1 Cannot find "lint" target for the specified project.
2 You can add a package that implements these capabilities.
3
4 For example:
5 ESLint: ng add @angular-eslint/schematics
6
7 Would you like to add ESLint now? (Y/n)
After we’ve answered this and all following prompts with Y the process will
install all relevant dependencies and add the necessary configuration to the
workspace. Afterward we can validate that everything worked out by re-
running the ng lint again…
1 ng lint
2
3 Linting "example-app"...
4
5 All files pass linting.
Schematics are hands down one of the best and the most powerful
features of the Angular!
thumb_down The issue with running the schematics from the terminal is that
in large projects, the paths can become very long and tedious or
even downright hard to type correctly, which would predictably
lead to the errors and frustration.
Luckily, most of the IDEs have built-in (or extension based) support for the
Angular Schematics which allows us to execute them on a selected folder in
the IDE file explorer instead of in the terminal.
This completely removes the need to type the paths as we only need to
provide final name of the component or service together with a couple of
flags (only the ones for which it doesn’t make sense to preconfigure them in
the angular.json file). The IDE will then take care of the rest and the use of
the schematics will become a walk in the park!
info The list of available schematics can be extended by the 3rd party
libraries and the custom schematics which can be created in the
organization to automate the repetitive tasks and to enforce the
best practices! A common addition schematics collection are
@ngrx/schematics or @angular-eslint/schematics ...
Now that we have selected the desired schematic to run, in our case the
component , there will be a last step where we have to provide the
component name and other flags. When generating component, all flags can
and should be preconfigured in the angular.json file to make sure our
codebase follows consistent code style!
159 Tomas Trajan
@tomastrajan
Hands on architecture
Once we hit Ok button, the component will be generated and the IDE will
print out the output of the schematics run in the IDE-based terminal…
1 /@angular/cli/bin/ng.js generate component avatar
2 CREATE projects/example-app/src/app/shared/avatar/avatar.component.scss (0 bytes)
3 CREATE projects/example-app/src/app/shared/avatar/avatar.component.html (21 bytes)
4 CREATE projects/example-app/src/app/shared/avatar/avatar.component.spec.ts (596 bytes)
5 CREATE projects/example-app/src/app/shared/avatar/avatar.component.ts (314 bytes)
6 Done
The only type of flags which can’t be preconfigured, and instead, we need to
pass them when we invoke schematics in our IDE are the ones that are used to
describe some kind of relationship between the generated code and the rest
of the codebase!
All these recent changes proved the invaluable position of the ng update
which enables Angular and hence also our projects to evolve and embrace
ever improving best practices and approaches without prohibitive cost of
manual migration.
In general, most Angular projects will use one of the 3rd party
component libraries like the official Angular Material, PrimeNG, Taiga UI
or others, but it’s also quite common that large enterprise organizations
will have enough resources to create and maintain their own in-house
component library which will be used across multiple projects.
1 ng add @angular/components
The chances are high that we’re going to answer Yes to all the questions,
and Custom to the theme prompt, which will allow us to customize the
theme to match colors of our organization.
The UPDATE files are the files which contain base setup required for
Angular Material to work properly. Instead of reading the
documentation, the ng add schematics did all the heavy lifting for us!
1
2 // Angular Material setup...
3
4 @tailwind base;
5 @tailwind components;
6 @tailwind utilities;
Hello world!
For example, assuming our previously defined custom color palette in the
styles.scss file, we should add it to the tailwind.config.js file…
1 module.exports = {
2 // ...
3 theme: {
4 extend: {
5 colors: {
6 blue: {
7 // make sure to keep in sync with custom palette styles.scss
8 50: '#eff6ff',
9 100: '#dbeafe',
10 200: '#bfdbfe',
11 // ...
12 },
13 },
14 },
15 },
16 };
projects/example-app/src/app/app.component.html
projects/example-app/src/app/app.component.scss
projects/example-app/src/app/app.component.spec.ts
After this changes, running the npm start script in the terminal should yield
an empty page in the browser, which is a good sign that we’re ready to start!
core/
ui/
layout/
feature/
pattern/
Core
The core folder will contain the injectables and other headless logic which
can be used in the eager part of the application and re-used between
multiple lazy features and patterns.
Layout
Now that our core is integrated and will be used to provide the base setup for
the application, we can start working on the layout where we’re going to
create the initial main layout!
Once we have pasted the imports content into the file, we need to
make sure that they are properly imported at the top of the file…
thumb_up It's always a good idea to let the IDE do the heavy lifting for us
and use its built-in functionality to automatically import the
missing entities which will add the necessary imports at the top
of the file...
Now it’s a great time to check out running application in the browser and see
how the main layout looks like and if everything is working as expected!
info The "routes" based lazy features are relatively new and don't have
a dedicated Angular Schematics to generate them yet. Because
of this, the proposed syntax might change in the future with
release of such schematics.
Let’s generate a first component of our newly created lazy feature by clicking
on the home/ folder, hitting the previously configured schematics key
shortcut, then selecting component schematics and generating a home
component…
176 Tomas Trajan
@tomastrajan
Hands on architecture
With the component in place, let’s update the home.routes.ts file to
include the route for the home component…
1 import { Routes } from '@angular/router';
2 import { HomeComponent } from './home/home.component';
3
4 export default <Routes>[
5 {
6 path: '',
7 component: HomeComponent
8 }
9 ]
The last piece of the puzzle is to integrate our lazy feature routes config in the
parent (in this specific case root) route configuration which is defined in the
app.routes.ts file…
The running application should display "home works!" in the middle part of
the screen!
The reason for that is that even if we were at the empty '' route, the
newly added root route configuration will redirect us to the home route
which will load the home lazy feature
Let’s hope that the route-based lazy features will get their own
dedicated schematics soon as well, but for now, we’re going to stick
with the proposed approach!
Every lazy feature can be shared out of the box and reused in multiple
other lazy features by integrating it as a sub-route within the route
configuration of the parent lazy feature!
This stands in contrast to the lazy sub features which are part of the parent
lazy feature and are implemented in parent lazy feature folder.
In that sense, a lazy sub feature can be extracted to become a standard lazy
feature in case when we need to share it with other lazy features besides the
parent lazy feature where it was implemented initially.
UI & Pattern
For now, we’re going to keep them empty and ready to implement shared
reusable UI components and patterns which can be used across multiple lazy
features when the need arises.
Here we’re just going to add the rules, please check the Automated
architecture validation in the theoretical part of the book to see an in-
depth explanation of the rules, their purpose and overall setup of the
automated architecture validation!
If the plugin was set up correctly, the linting should run and print output
similar to the one provided above. The error is expected because we haven’t
provided the element types configuration yet and the plugin is therefore not
able to recognize the files in the workspace!
Next step is to add the boundaries/elements settings which define
architecture types and which folders and files belong to them…
1 {
2 "overrides": [
3 {
4 "files": ["*.ts"],
5 "settings": {
6 // most configuration was skipped to focus on the "boundaries/elements"
7 "boundaries/elements": [
8 {
9 "type": "main",
10 "mode": "file",
11 "pattern": "main.ts",
12 "basePattern": "projects/**/src",
13 "baseCapture": ["app"]
14 },
15 {
16 "type": "app",
17 "mode": "file",
18 "pattern": "app(-|.)*.ts", // app-routes, app.component, ...
19 "basePattern": "projects/**/src/app",
20 "baseCapture": ["app"]
21 },
22 {
23 "type": "core",
24 "pattern": "core",
25 "basePattern": "projects/**/src/app",
26 "baseCapture": ["app"]
27 },
28 // continues on the other page...
Great, all the files in the application are now recognized and assigned to their
respective architecture types.
This is the reason why we have defined also helper types like main and app
which are used to capture the files which are part of the base setup of every
Angular application.
These helper types are consuming the files from the other architecture types,
for example core is consumed in app.config.ts or layout is consumed
in app.component.ts and therefore their relationship has to be defined as
well!
The application folder often contains also files which are not part of the
implementation itself but are used to configure additional tooling, especially
unit and end-to-end testing.
Because of the plugin:boundaries/strict preset, these files have to be
accounted for as well, and we have to either create a new architecture type
for them, e.g. testing or add them to the "boundaries/ignore": [] array.
183 Tomas Trajan
@tomastrajan
Hands on architecture
Example of boundaries/ignore configuration for application which uses
Jest testing framework and especially Typescript-based Jest configuration…
1 {
2 "overrides": [
3 {
4 "files": ["*.ts"],
5 "settings": {
6 "boundaries/ignore": [
7 "**/jest(.|-)*.ts",
8 ]
9 }
10 }
11 ]
12 }
Now that we know how we could account also for additional files that can
become part of the application, we can continue with the last step of
automated architecture validation, which is the setup of the rules…
1 {
2 "overrides": [
3 {
4 "files": ["*.ts"],
5 "rules": {
6 "boundaries/element-types": [
7 "error",
8 {
9 "default": "disallow",
10 "rules": [
11 {
12 "from": "main",
13 "allow": [["app", { "app": "${from.app}" }]]
14 },
15 {
16 "from": "core",
17 "allow": [["lib-api"], ["core", { "app": "${from.app}" }]]
18 },
19 {
20 "from": "ui",
21 "allow": [["lib-api"], ["ui", { "app": "${from.app}" }]]
22 },
23 // continues on the other page...
With the rules in place, the ng lint command should run without any errors
or warnings.
1 ng lint
2
3 Linting "example-app"...
4
5 All files pass linting.
As with every test, and yes, the automated architecture validation is a test of
the architecture, it’s always a good idea to try to break it and see if it catches
the issues. That way we can have full confidence in its capabilities!
To do that, let’s import the HomeComponent in the main-
layout.component.ts by adding it to the imports: [ ] array of the
@Component decorator…
3 @Component({
4 selector: 'my-org-main-layout',
5 standalone: true,
6 imports: [
7 // other imports...
8 HomeComponent // <-- add to the imports
9 ],
10 templateUrl: './main-layout.component.html',
11 styleUrl: './main-layout.component.scss',
12 changeDetection: ChangeDetectionStrategy.OnPush
13 })
14 export class MainLayoutComponent {}
Now running the ng lint command should yield an error similar to the one
provided below…
1 ng lint
2
3 Linting "example-app"...
4
5 /home/tomastrajan/projects/github/my-org-workspace/projects/example-app/src/app/layout/main
6 7:31 error No rule allowing this dependency was found. File is of type 'layout'
7 with app 'example-app'. Dependency is of type 'feature' with app 'example-app'
8 and feature 'home' boundaries/element-types
More so, the error should be visible in the IDE as well, to provide immediate
feedback!
Let’s illustrate a process of adding a new architecture type and related rules
with an example of adding a new env type which will be used to capture the
Angular environments files which were previously generated out of the box
with every new Angular CLI workspace, but nowadays are only added if we run
the ng g environments schematics…
1 ng g environments
2
3 CREATE projects/example-app/src/environments/environment.ts (31 bytes)
4 CREATE projects/example-app/src/environments/environment.development.ts (31 bytes)
5 UPDATE angular.json (3726 bytes)
Once the environments are generated, we can re-run the ng lint command
to see that the environments files are not recognized and are therefore not
part of the automated architecture validation…
1 ng lint
2
3 Linting "example-app"...
4
5 /home/tomastrajan/projects/github/my-org-workspace/projects/example-app/src/environments/env
6 1:1 error File is not of any known element type boundaries/no-unknown-files
7
8 /home/tomastrajan/projects/github/my-org-workspace/projects/example-app/src/environments/env
9 1:1 error File is not of any known element type boundaries/no-unknown-files
Re-running the ng lint command should yield a clean output without any
errors or warnings!
1 ng lint
2
3 Linting "example-app"...
4
5 All files pass linting.
Now we still need to define the rules for the env type in the
.eslintrc.json file…
Great, now we have built a solid understanding and foundation for the
architecture of the application and have set up the automated
architecture validation to ensure that it stays clean!
With all this in place, we’re now ready to start building the actual application
and implementing the features and patterns which will make it unique and
valuable to our users!
190 Tomas Trajan
@tomastrajan
Hands on architecture
Example project
The book comes with an example project repository which contains the full
implementation of the architecture skeleton and the automated architecture
validation setup together with a couple of feature and pattern examples and
stubs for UI components.
info Example project is a great starting point for many new Angular
projects, and especially those which were planning to use
Angular Material and Tailwind CSS as the UI and utility libraries in
the first place!
Both eBook and example project repository are living documents and
will be updated over time, see release history. Everyone who purchased
the eBook will then receive email notification about future releases and
will be able to download the latest version!
When using schematics to generate new apps and libs, the linting setup
will be automatically added to the new projects and will be ready to use
without any additional effort. This is because the architecture validation
is set up on the root level and is therefore inherited by all the projects in
the workspace!
info The proposed approach and architecture will work really well in a
small-to-medium size workspaces with up to 10 apps and libs (but
this is highly dependent on their individual size).
info When working with large (or multiple) teams, it might be very
beneficial to use CODEOWNERS file to define the owners of the
different parts of the workspace and to ensure that when
developers need to make changes in the parts of the workspace
that are shared between multiple teams (this can be on level of
application but also on a level of individual feature) the resulting
pull request will be reviewed by the right people!
tweet (or newly post on X) - a specific quote from the book, an individual
page with content you found valuable or just a general recommendation
testimonial - using this super short form, please provide a single paragraph
or a couple of sentences about what you enjoyed about the book
(preferably with your profile picture), name, role and your location (e.g.
USA or Thailand) and a consent to post it to the book website