Top 10 Angular Architecture Mistakes You Should Avoid
This article discusses 10 common mistakes to avoid when applying Angular architecture to build your apps, along with steps and tools to help prevent them.
Join the DZone community and get the full member experience.
Join For FreeEven in 2024, Angular keeps changing for the better at an ever-increasing pace, but the big picture remains the same; this makes Angular architecture knowledge timeless and well worth your time.
The Angular renaissance continues, and it’s going stronger in 2024 with the Angular team delivering amazing things like signal-based input
/ outputs
/ viewChild
(ren) / contentChild
(ren), new control flow, @defer
for seamless component lazy loading all the way to the zone-less change detection which was released in preview in Angular 18 in May.
These new APIs, syntaxes, and approaches are amazing and have a demonstrably positive impact on the developer experience, performance, and quality of the codebases. But, at the same time, they don’t really have much effect on the way we structure our applications and workspaces.
The way we structure and architect our Angular application stayed virtually the same from the times of ancient Angular 4, which brought us stable Router
implementation with support for lazy-loaded routes, which is one of the most important building blocks of clean architecture (not just performance) until today.
Of course, there have been some API adjustments over time, but they mostly had an effect on the lower (syntax) level instead of completely changing the way we’re doing things.
The best example to illustrate this point is the advent of standalone components and APIs, which have made NgModules
optional. The impact of this change is that instead of lazy loading feature modules, we’re now lazy loading feature route configs (or root feature components). Is there a difference? Sure. Does it change how things are organized and structured on the higher architectural level? Not at all.
Common Mistakes in Angular Application Architecture
1. Not Having an Architecture (Or a Plan) at All
We often hear about moving fast and breaking things, and it is a desirable thing because it allows us (in theory) to react to the ever-changing demands swiftly and helps our app or product to stay relevant.
Unfortunately, what is often forgotten is that if we want to be able to keep moving fast since starting our project, then we also need to make sure that our project doesn’t become an overconnected ball of mud, or in more technical terms, we don’t end up with a tangled dependency graph with lots of circular dependencies.
Working without a clear idea about architecture tends to end up in situations as depicted above. This changes our original statement:
“Move fast and break things.”
into something more akin to:
“Try to change this one thing, break everything.”
One of the best ways to know if this is your case is the constant feeling of being stuck in a spider web when trying to fix one thing that leads you to a journey of changing half of the codebase, adding condition after condition to make it work "just this one more time."
To summarize, yes, we need to think about architecture to keep moving fast and break little isolated things (without impact on the whole) throughout the project's lifetime.
2. Not Thinking About the Difference Between Eager and Lazy Parts of the App
In Angular (and front end in general), we try to minimize the amount of JavaScript that we need to load initially because it has a huge positive impact on the startup performance for the end users.
Nowadays, the problem is NOT the speed of the network connection but the lack of CPU power in weaker devices that need to parse and execute all that downloaded JavaScript.
So naturally, most of the existing Angular apps will have concepts like core (eager) and feature, page, or view (lazy) to account for this base underlying reality of web development in general.
However, having concepts and corresponding folders is not enough; we also need to ensure we don’t accidentally break this separation over the project's lifetime. A typical example is when we have a feature-specific service that manages some sort of feature-specific state. Then, we realize that it would be handy to access that state in a service within the eager core as well.
In that case, it’s straightforward to import and inject the feature service in the core service and overlook this eager or lazy boundary violation (or just miss it during the PR review). The outcome will be that the service (and everything else it imports) will suddenly become part of the eager JavaScript bundle.
This is bad for both performance and architecture, as we’re introducing dependencies between parts that should have been isolated or only depend on each other in one direction, e.g., feature can consume core, but not the other way around. Over time, this often leads to a situation depicted in the description of the first mistake of this article with an excessively tangled dependency graph.
3. Not Lazy Loading All the Features
Another common offender that tends to happen also in reasonably architected projects is that even though they mostly embraced the eager/lazy split and have most of the logic implemented as the lazy loaded features, for some reason, some of the features are “forgotten.” The most common offenders I have seen in the wild are:
- Login/Sign Up
- Generic Error Page/404 Page
- First Implemented Feature (e.g., Home or Dashboard)
The last example is the most common and, at the same time, the most unfortunate, especially because it is easy to empathize with why something like this would happen in practice.
Imagine a situation where we’re working on a new application, and we are working on the first set of business requirements where we need to display some kind of data. We don’t have any navigation yet, so we start creating components and using them recursively in the template all the way up to the root AppComponent
.
Then, new requirements would come in, and we would need to add navigation. The new feature would be implemented properly as a lazy-loaded feature. Unfortunately, there usually won’t be time (budget or will) to refactor the original feature to be lazy-loaded.
Now, we have reached a situation where there are at least two ways of doing things (eager and lazy features), plus our isolation and performance suffer as well. The best way to remedy this is to always implement everything, including the first (original feature) as a lazy-loaded feature. Or, in other words...
Even a one-pager (single feature) application without navigation, this first page/feature should be implemented as the first lazy-loaded feature.
The cost of this is very minimal, and we will thank ourselves that we did that soon enough!
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'dashboard'
},
// application with a single feature
// implemented as a first lazy loaded feature
{
path: 'dashboard',
loadChildren: () => import('./features/dashboard/dahsboard.routes.ts')
.then(m => m.routes)
}
]
4. Using More Than One Way To Achieve the Same Result
Building on top of the previous point, we should always strive to minimize the amount of ways we’re doing things in our workspace. Let’s take routing as an example; currently there are at least four ways to do it:
-
Eager route to a component with
component
-
Lazy route to a component with
loadComponent
-
Lazy route to a module with
loadChildren
-
Lazy route to routes-based feature (
feature-x.routes.ts
) withloadChildren
In this case, we most likely want to pick one and stick with it.
I would personally recommend to always define lazy route with the routes-based feature with loadChildren
, which is the most modern and flexible way of doing things. Then, if our lazy feature contains sub-navigation, we can lazy load additional components with loadComponent
.
We should do this also (especially) if our lazy feature has only one component to start with because the chances are high that the requirements will be extended or adjusted in the future.
The proposed approach allows us to seamlessly grow to any amount of complexity while maintaining a single unified way of doing this across the whole project, which removes cognitive load because everything looks and is done the same way.
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
loadChildren: () => import('./features/dashboard/dahsboard.routes.ts')
.then(m => m.routes)
}
]
// dahsboard.routes.ts (routes based lazy feature)
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./dahsboard.component.ts')
.then(m => m.DashboardComponent)
},
// which is easy to extend with in the future, eg
{
path: 'editor',
loadComponent: () => import('./dahsboard-editor.component.ts')
.then(m => m.DashboardEditorComponent)
},
// or a larger sub-feature
{
path: 'forecast', // forecast sub lazy feature added later
loadChildren: () => import('./forecast/forecast.routes.ts')
.then(m => m.routes)
}
]
5. Focusing On DRY Instead of Isolation
Isolation reduces coupling and often opposes another well-known approach in software engineering, the DRY, which stands for “Don’t repeat yourself.” DRY is a principle that aims to reduce 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 necessitates a change in how it should work will require us to change the logic consistently in all these places, which can lead to errors and inconsistencies.
As it turns out, in front-end applications, it's 3-10x 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 argument we’re trying to make is that in front-end codebases; it’s better to have some amount of duplicated code (e.g., in multiple lazy features) because that will allow them to evolve independently as the requirements change, and they do change.
In front end, it’s also much more common to encounter requirements that are very ad hoc, e.g., to custom rule as part of a specific flow for a tiny subset of users. It’s also much more common to encounter very specific requirements. Because of this, the isolation and the flexibility it provides are much more valuable than abstracting away every instance of repetition.
6. Analyzing Architecture Manually Instead of Utilizing Tooling
Angular CLI workspace doesn't really provide any great tools for workspace architecture analysis out of the box. This is probably the main reason why this topic is often unexplored and is outside the general discourse of Angular developers in online communities worldwide.
On the other hand, NX based workspaces come with a great way to analyze architecture and dependency graph with
nx graph
command.
An example of manual architecture validation, something like verifying isolation between lazy-loaded features is a very tedious error-prone process. We would have to:
- Use our editor search functionality.
- Select a specific feature folder, e.g.,
feature-a
. - Then, search for
../feature-b
(and all other features) to detect if feature A contains a relative import to any of the sibling features, which means there is a violation of the rule that sibling lazy features should be isolated from one another.
Such an approach, while technically possible, is something that is, at best, frustrating, and no one in their right mind would want to keep doing that on a regular basis. So, what are other available alternatives?
Madge
Madge is a developer tool for generating a visual graph of your module dependencies, finding circular dependencies, and giving you other helpful information. It's one of my favorites and one of the best tools for determining the health of the Angular codebase, which allows us to visualize the dependency graph of the project with a single command. That's it! Just make sure to adjust paths based on your exact workspace structure.
npx madge src/main.ts --ts-config tsconfig.json --image ./deps.webp
Madge will crawl through all .ts
files (and the files they import) and produce an easy-to-understand chart that could look like this:
GOOD: Does the graph look organized, flowing left to right?
BAD: Does it look like it was made by a bunch of drunk spiders?
We can also use Madge to:
- Determine codebase health.
- Find areas to improve.
- Communicate with non-technical colleagues.
The last point can come in extremely handy when often justifying much-needed refactoring/technical debt-clearing efforts, which can benefit the organization by increasing overall shipping velocity but are usually hard to explain and communicate properly.
ESLint Plugin Boundaries
Using eslint-plugin-boundaries
is one of the easiest and best ways to guarantee that the Angular application architecture stays clean throughout the project's lifetime. It allows us to define types and rules that encode our desired architecture with a couple of lines of configuration.
This plugin works just with folder structure, which means there is no additional overhead and no changes in the actual implementation of the application itself. The architecture as described earlier could be then expressed with the following architectural type config:
{
"overrides": [
{
"files": ["*.ts"],
"plugins": ["boundaries"],
"settings": {
"boundaries/elements": [
{
"type": "core",
"pattern": "core"
},
{
"type": "feature",
"pattern": "feature/*",
"capture": ["feature"]
}
]
}
}
]
}
With the types in place, we can then define rules that govern allowed relationships within such dependency graph:
{
"overrides": [
{
"files": ["*.ts"],
// rest omitted for brevity
"rules": {
"boundaries/element-types": [
"error",
{
"default": "disallow",
"rules": [
{
"from": "core",
"allow": ["core"]
},
{
"from": "feature",
"allow": ["core"]
},
]
}
]
}
}
]
}
Such a rule set prevents imports between features (would break isolation) as well as imports from feature into core (would break eager/lazy boundary).
This is quite amazing because it’s a fully automated way to validate architecture on every single pull request (or build). It provides a bulletproof guarantee that it stays clean for the whole project's lifetime.
7. Not Thinking About the Dependency Graph
As we have seen across multiple previous points, clean architecture and architecture in general are very strongly related to the underlying dependency graph of our codebase. Even though it’s not readily visible when working on code in individual files, we should always remember what is going on behind the scenes and the impact of our changes on the big picture.
In general, we want to make sure that the three following points are always considered and preserved as much as possible:
- We want to preserve the one-way nature of the dependency graph. This is synonymous with preserving clean eager/lazy boundaries in our codebase and can be extended further in a one-way direction when lazy sub-features are allowed to import from parent lazy features but not the other way around.
- We want to preserve isolation between independent branches of the dependency graph. This maps one-to-one on the concept of full isolation between sibling lazy features (on the same level of navigation).
- On a more micro scale, we want to prevent any cycles within the dependency graph. This often leads to situations that break the two previous points, as well as just making it harder to take things apart once we want to re-use and, therefore, extract a piece of feature-specific logic.
8. Not Having a Clear Concept for Sharing Components and Logic
Another common issue in many codebases can be illustrated with the following scenario:
We have already implemented two isolated lazy features (excellent!) and now, we need to add a third one. As it turns out, one of the components from feature A could help us solve a similar use case in the new feature C. In such cases, what unfortunately tends to happen very often is that we just import that standalone component from feature A to feature C and call it a day.
The application works, and the lazy bundling still mostly works, but we have introduced invisible coupling between the features and, therefore, lost the desired isolation between these features with most of its benefits.
A single component is not the end of the world, but such cases tend to increase over time. This again leads to a tangled dependency graph and the inability to change feature A without affecting feature C, which reduces our velocity and often introduces regression. So, what could we do instead?
In this case, if we had a well-defined concept like ui
for generic reusable components, the right thing to do would be to extract the desired component from feature A into the ui
. This, in practice, also means that we would need to:
- Clean up any feature-specific logic to make that component fully generic, which is often possible (and desirable).
- Move the component to the
ui/
folder. - Import and integrate it in both feature A and feature C.
After that, we can use newly extracted generic component in both features without any issues. The one-way dependency graph, isolation and therefore clean architecture is fully preserved.
9. Not Being Familiar With the Two Main Systems in Angular and the Rules by Which They Behave
When working with Angular, everything is governed by two main underlying systems:
- Template context: What can we use in the template of component A?
- Injector hierarchy: Which instance of an injectable are we going to inject in component A (or service A)?
Similar to the underlying base realities of lazy loading and JavaScript bundles, these two systems represent the same base reality from the Angular perspective and, therefore, impact everything in our codebase, especially on the architecture.
The impact on the architecture is then all about where we want to implement our components or services so that they can be used in templates (or injected) in the features in a way that preserves clean architecture.
A great example of this is scoping of feature-specific services to that specific feature by removing the providedIn: 'root'
option from the @Injectable()
decorator and providing it in the lazy feature routes config instead.
export const routes: Routes = [
{
path: '',
providers: [ProductService], // scoping service to a lazy feature
children: [
{
path: '',
loadComponent: () =>
import('./product-list/product-list.component').then(
(m) => m.ProductListComponent)
},
],
},
];
That way, we can prevent the accidental consumption of a service that should only ever be consumed in feature A from feature B. And, if such a requirement is valid, we are forced to do it in a clean way, for example, by extracting the service one level up parent lazy feature or even up to the core.
10. Not Using Standalone Components
Angular brought support for standalone components since version 14, so it has been more than two years since the NgModule
s became optional.
Standalone components bring the most value and are hands down the best solution, especially when implementing reusable/generic UI components (components that communicate only using inputs and outputs and are not bound to any specific business logic or data source).
Using standalone components instead of
NgModule
s makes our dependency graph more granular. This higher resolution allows us to better understand how each part of the app relates to the others and to discover more issues.
Besides that, it allows lazy features to cherry-pick only those UI components that are actually needed instead of depending on all of them, as was often the case when applications grouped and exposed them in commonly used SharedModule
.
Angular Architecture Can Be Awesome
Building an Angular application can be quite rewarding, and learning about these common mistakes can provide you with ideas for actionable items to improve your existing apps and, even more so, when starting new projects.
Published at DZone with permission of Tomas Trajan. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments