Sustainable Angular Architectures
with Strategic Design and Monorepos - Part 2: Implementation
In the first part of this series, I’ve presented the idea of Strategic Design which allows to subdivide a software system into several self-contained (sub-)domains. In this part, I show how to implement those domains with Angular and an Nx-based monorepo.
For this, I’m following some recommendations the Nx team recently wrote down in their free e-book about Monorepo Patterns. Before this was available, I’ve used similar strategies but in order to help establishing a common vocabulary and common conventions in the community, I seek to use this official way.
Implementation with Nx
For the implementation of the defined architecture, a workspace based on Nx [Nx] is used. This is an extension for the Angular CLI, which among other things helps to break down a solution into different applications and libraries. Of course this is just one of several possible approaches. As an alternative, one could, for example, implement each domain as a completely separate solution. This would be called a micro-app approach.
The solution shown here puts all applications into one apps
folder, and all the reusable libraries are grouped by the respective domain name in the libs
folder:
Because such a workspace consists of several applications and libraries that are managed in a common source code repository, there is also talk of a monorepo. This pattern is used extensively by Google and Facebook, among others, and has been the standard case for the development of .NET solutions in the Microsoft world for about 20 years.
It allows the sharing of source code between the project participants in a particularly simple way and also prevents version conflicts by having only one central node_modules
folder with dependencies. This ensures that e.g. each library uses the same Angular version.
To install Nx you can use the following command:
npm install -g @nrwl/schematics
In order to create the CLI-based monorepo workspace, just leverage the create-nx-workspace
command. Then, you can use ng generate
to add applications and libraries:
create-nx-workspace e-proc
cd e-proc
ng generate app ui
ng generate lib feature-request-product
Categories for Libraries
In their free e-book about Monorepo Patterns, Nrwl — the company behind Nx — use the following categories for libraries:
- feature: Implements a use case using smart components
- data-access: Implements data accesses, e.g. via HTTP or WebSockets
- ui: Provides use case agnostic and thus reusable components (dumb components)
- util: Provides helper functions
In addition, I’m also using the following categories:
- shell: For an application that contains multiple domains, a shell provides the entry point for a domain
- api: Provides functionalities exposed for other domains
- domain: Domain logic like calculating additional expanses (not shown here). Can be consolidated with a corresponding
data-access
library to simplify the design.
To keep the overview, the categories are used as a prefix for the individual library folders. Thus, libraries of the same category are presented next to each other in a sorted overview.
Each library also has a public API through which it publishes individual components. On the other hand, they hide all other components. These can be changed as desired:
export * from './lib/catalog-data-access.module';
export * from './lib/catalog-repository.service';
Check accesses to libraries
To improve maintainability, it is important to minimize the dependencies between the individual libraries. The achievement of this goal can be checked graphically with Nx. For this, it provides the dep-graph
npm script:
npm run dep-graph
If we just concentrate on the Catalog
domain in our case study, the result looks as follows:
In the case considered here, a few rules are used for the communication between libraries and these lead to a consistent layering. For example, each library may only access libraries from the same domain or shared libraries.
Access to APIs such as catalog-api
must be explicitly granted to individual domains.
The categorization of libraries also has limitations: a shell
only accesses features
and a feature
accesses data-access
libraries. In addition, anyone can access utils
.
To enforce such restrictions, Nx comes with its own linting rules. As usual, they are configured within tslint.json
:
"nx-enforce-module-boundaries": [
true,
{
"allow": [],
"depConstraints": [
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:shell"] },
{ "sourceTag": "scope:catalog", "onlyDependOnLibsWithTags": ["scope:catalog", "scope:shared"] },
{ "sourceTag": "scope:shared", "onlyDependOnLibsWithTags": ["scope:shared"] },
{ "sourceTag": "scope:ordering", "onlyDependOnLibsWithTags": ["scope:ordering", "scope:shared"] },
{ "sourceTag": "type:shell", "onlyDependOnLibsWithTags": ["type:feature", "type:util"] },
{ "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:data-access", "type:util"] },
{ "sourceTag": "type:api", "onlyDependOnLibsWithTags": ["type:data-access", "type:util"] },
{ "sourceTag": "type:util", "onlyDependOnLibsWithTags": ["type:util"] },
{ "sourceTag": "name:ordering-feature", "onlyDependOnLibsWithTags": ["name:catalog-api"] }
]
}
]
According to a suggestion from the mentioned e-book about Monorepo Patterns, the domains are named with the prefix scope
and The library types are prefixed with kind
. Prefixes of this type are only intended to increase readability and can be freely assigned.
In addition, this example also shows the domain Ordering which, according to the context mapping, has access to the CatalogApi
. For this, the example uses a name prefix to make sure that only selected libraries are allowed to access the api.
The mapping between the projects and the library types and domains shown here takes place in the file nx.json
:
"projects": {
"ui": {
"tags": ["type:app"]
},
"ui-e2e": {
"tags": ["type:e2e"]
},
"catalog-shell": {
"tags": ["scope:catalog", "type:shell"]
},
"catalog-feature-request-product": {
"tags": ["scope:catalog", "type:feature"]
},
"catalog-feature-browse-products": {
"tags": ["scope:catalog", "type:feature"]
},
"catalog-api": {
"tags": ["scope:catalog", "type:api", "name:catalog-api"]
},
"catalog-data-access": {
"tags": ["scope:catalog", "type:data-access"]
}, [...], "ordering-feature": { "tags": ["scope:ordering", "type:feature", "name:ordering-feature"] }, [...],
"shared-util-auth": {
"tags": ["scope:shared", "type:util"]
}
}
Alternatively, these tags can also be specified when setting up the applications and libraries.
To test against these rules, just call ng lint
on the command line. Development environments such as WebStorm / IntelliJ or Visual Studio Code show such violations while typing. In the latter case, a corresponding plugin must be installed.
Don’t expect that those rules always guarantee a beautiful dependency graph without overlappings as shown above. However, if you put each domain into a block diagram where each layer is only allowed to access layers beneath it, you can clearly see that you have got a clean and comprehensible architecture:
Moreover, everyone clearly sees where specific parts of the application are supposed to be found and the introduced rules prevent cycles, at least if APIs can only be accessed by features of other domains.
Conclusion and Evaluation
Strategic Design provides a proven way to break an application into self-contained domains. These domains are characterized by their own specialized vocabulary, which must be used rigorously by all stakeholders.
The CLI extension Nx provides a very charming way to implement these domains with different domain-grouped libraries. To restrict access by other domains and to reduce dependencies, it allows setting access restrictions to individual libraries.
Of course, it can be argued that an Angular client does not necessarily include domain logic. But the fact is that more and more logic can also be found on the client, especially with single page applications. Regardless, the outlined ideas of Strategic Design have proven to be extremely useful for getting a good cut.