Angular Architecture for Large Web Applications
A modular monolith: Optimizing monolithic projects with libraries.
Updated on July 4th, 2024.
In the exciting world of web development with Angular, we face challenges when dealing with growing monolithic projects. This article explores common problems and presents a reasonable solution to enhance efficiency and flexibility in developing large-scale Angular web applications.
🏔️ Challenges of Monolithic Projects
🕰️- Compilation Time and Testing Cycle:
As code grows within a single project, we encounter an increase in compilation time and a slowdown in the testing cycle. This can negatively impact productivity and the responsiveness of the development team, potentially leading to the abandonment of test adoption during development.
📑 - Module-less Architecture:
In modern versions of Angular, the new standalone components and APIs architecture, greatly facilitates development and lowers the entry barrier for new programmers. However, removing the boundaries modules provide can create chaos where any artifact is visible throughout the whole application. This allows for complicated dependencies and hinders the implementation of robust software architectures.
🚞 The Solution: Specialized Libraries with Angular
The proposed solution to overcome these challenges is based on the creation of specialized libraries, replacing the role of traditional modules with greater flexibility and efficiency.
🧪 + Test-Driven or Test-Supported Development:
Each library can be compiled and tested independently, even with specific tools based on its content and technological foundation. This facilitates development and speeds up feedback cycles.
📦 + Abstraction and Hiding Details:
Each library exposes functionalities while hiding implementation details. This well-established practice simplifies refactoring and aids in issue detection.
🧑⚖️ + Dependency Rules:
Dependency rules can be established between libraries, allowing the creation of hierarchies in line with common software architectures, such as layered or clean architecture.
🔮 + Future-Ready:
Nobody knows anything about the future, except there will changes. The proposed structure facilitates adaptation to Micro-front-end architectures and coexistence with other technologies or Angular versions in the long term.
⚔️ Technical and functional division
I have based the proposed folder structure on the one presented in my previous article for medium-sized applications:
🏷️ Tags for Feature-Technical Division
In that post, I have encouraged group code by functionality. This is commonly referred to as vertical slicing. But there is also the possibility of physically reflecting the technical division in libraries, horizontal layering.
Those two dimensions are valid approaches for dividing your monolith in libraries. But, most important is that you can use them to establish a set of rules among your libs.
To do so, I will assign a tag name to each concept:
- ⏩ Horizontal: technical type.
- ⏬ Vertical: functional scope.
Type and Scope can be represented in a table like this simplified example, highlighting the direction of allowed and restricted dependencies.
Tools like Nx or sheriff helps you to apply these tags to any project and Madge allows to visually get control of your dependencies.
🛠️ Recommended Tools: Nx.dev or Sheriff
Tools beyond the Js/Ts standard are needed to implement these rules. Not even Angular offers anything specific. You need to use plugins for es-lint, such as Sheriff.
To have a visual representation of your dependency tree, there is Madge, who generates a graph from your code.
I also recommend try using tools from Nx.dev, which significantly enhance the developer experience. Among other things, it does any of the former tools and have strong documentation and support.
🧑💻 Practical Example: GitHub Lab
You can explore a practical example in my GitHub lab: Nx Lab Repository. The repository details the Nx commands used for workspace generation and all libraries.
This is how it started…
# Create an empty repository [nx-lab]
npx create-nx-workspace@latest nx-lab --preset=empty
cd nx-lab
# Install Angular plugin
npm i -D @nx/angular
# Generate Main Application [activity-bookings] [lab]
npx nx g @nx/angular:app activity-bookings --bundler=esbuild
--e2eTestRunner=none -p=lab -s -S --style=css --ssr
--unitTestRunner=none -t --tags=type:app
This is the result in the VSCode of the example’s multi-project mono-repository.
👮♂️ Dependency Boundaries
At the eslint file, pay special attention to the section where project dependency limits are set, facilitated by Nx tools.
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "scope:*",
"onlyDependOnLibsWithTags": ["scope:shared"]
},
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:feat"]
},
...
🌿 Granularity of Libraries
When creating libraries, granularity is critical. You can start from a well organized monolith, and create with three large libraries based on the main folders: core, routes, and shared.
This approach is easy and allows to further divide them according to project needs. Specially, I will show you how I subdivide the Routes and Shared stuff.
🧭 Library per Route:
Having at least one library for each main route is recommended to facilitate management and maintenance.
You can continue slicing and creating specialized libraries for each page from here. Even treat each functional route as a mini-application with its specialized UI, Domain, and Data service libraries.
🧰 Shared Container Division:
I recommend subdividing the shared container using the same folder structure you got.
First, extract features like logging or security, following the principle of maintaining cohesive and potentially reusable solutions. This is just another functional vertical division.
But there are another benefit of using libraries: you can have specialized toolkits and workflows based on the nature of the things it exposes. As an example, think about the different testing tools and strategies to check your visual components and, may be, pure functions or entity classes.
🔬 Specialized Testing Strategies
As said before, technical divisions are also interesting as they allow the use of more specific tools. For example, some libraries can focus on Angular declarables, other have use of injectables, while others have no relationship with the framework.
🌲 Visual Testing with Cypress:
Libraries specialized in the presentation layer expose Angular components. Testing the UI in a traditional unitary way can be a pain.
But for some time now, they can be visually and user-friendly tested with Cypress. With the advent of component testing, they have provided an efficient and enjoyable development experience.
For this reason, I usually extract my dumb components to a library, to which I then add Cypress as a component test framework.
# shared ui library
npx nx g @nx/angular:library shared/ui -c=OnPush
--importPath=@lab/ui -p=lab --projectNameAndRootFormat=as-provided -s
--skipTests --style=css --unitTestRunner=none -t
--tags=type:ui,scope:shared
# Component testing with cypress for ui library
npx nx g @nx/angular:cypress-component-configuration --project=ui
--buildTarget=activity-bookings:build:development
--generateTests
Other tools like playwright also allows for visual component testing.
👔 Pure Libraries for Data and Business Logic:
In a broad context, a domain refers to the specific area or topic on which an application or system is being built. Extracting data definitions and business logic to pure libraries, without contamination from any framework, allows the use of standard unit testing tools and is potentially reusable between technologies.
If you opt for that, this domain lib will be the core of a clean architecture. In my experience, most front-end developments don’t take it to the extreme due to the complexity of abstraction and dependency inversion they require. But every middel-size app can benefit from extracting some validation, transformation rules to a function library.
# domain
npx nx g @nx/js:lib shared/domain --bundler=esbuild
--importPath=@lab/domain --projectNameAndRootFormat=as-provided
--unitTestRunner=none --tags=type:domain,scope:shared
# Unit testing with jest for domain library
npx nx g @nx/jest:configuration --project=domain
All those libraries can be spotted in a graph generated by Nx.
✅ Validated in Large-Scale Projects
In my experience as a trainer and consultant, I have seen that this solution has been tested in developing projects for large companies.
It is what we call a modular monolith. It retains the simplicity of the single deployment, but allowing for reuse and a test each module (a group of related code files) in more controlled way.
It demonstrates effective scalability for any size or desired architecture, including Micro-front-end and clean architectures.
At the same time, it retains simple concepts of smaller applications: core, routed, and shared.
I hope these ideas contribute to the clarity and effectiveness of your developments. Please let me know if there’s anything else I can help with or correct!
I hope you find this useful and that you share it if you think it can help others.
Elevating code quality.