-
Notifications
You must be signed in to change notification settings - Fork 0
NX architecture tips
Extracted from "Lightweight Angular with Standalone Components" and “Enterprise Angular: Micro Frontends and Moduliths with Angular” (https://www.angulararchitects.io)
Each book has its own repository:
- https://github.com/manfredsteyer/demo-nx-standalone
- https://github.com/manfredsteyer/strategic-design
"Standalone Components make the future of Angular applications more lightweight. We don’t need NgModules anymore. Instead, we use EcmaScript modules. This makes Angular solutions more straightforward and lowers the entry barrier into the world of the framework. Thanks to the mental model, which regards standalone components as a combination of a component and a NgModule, this new form of development remains compatible with existing code. For the grouping of related building blocks, simple barrels are ideal for small solutions. For larger projects, the transition to monorepos as offered by the CLI extension Nx seems to be the next logical step. Libraries subdivide the overall solution here and offer public APIs based on barrels. In addition, dependencies between libraries can be visualized and avoided using linting."
- use when possible standalone components over NG Modules.
- use barrels to get modularization. (pàg. 10).
- use whole barrels when possible to export always related classes and import as an array. (pàgin. 11).
- define paths in tsconfig file to import elements with short, pretty, and descriptive naming.
“The Angular team recommends using this providers array with caution and favoring providedIn: 'root' instead. As already mentioned in a previous chapter, also providedIn: 'root' allows for lazy loading. If you just use a service provided with providedIn: 'root' in lazy parts of your application, they will only be loaded together with them.
However, there is one situation where providedIn: 'root' does not work, and hence the providers array shown is needed, namely if you need to pass a configuration to a library. I’ve already indicated this in the above example by giving a config object to my custom provideBookingDomain."
Services that can be used globally should use `providedIn: ‘root’ while component-specific services should be provided directly in that component’s decorator metadata.
There should be a dedicated *.routes.ts file for each feature / domain as the entrypoint
- use lazy loading of components.
- set ngrx as provider for bootstrapping apps.
- set ngrx as provider for lazy loaded slices.
"Feature libraries contain smart components that implement use cases, while UI libraries house reusable dump components. Domain libraries encapsulate the client-side domain model and services that operate on it, and utility libraries group general utility functions."
“With the linting rules mentioned, it can now be ensured that each layer may only access the layers below it. Access to other domains can also be prevented. Libraries from the Booking area are therefore not allowed to access libraries in Boarding. If you want to use certain constructs across domains, they should be placed in the shared area, for example.”
Each model is only valid for a specific area:
“This is the key to decoupling domains from each other. While this might lead to redundancies, very often it doesn’t because each domain has a very unique perspective to its entities.”
“This approach prevents the creation of a single confusing model that attempts to describe the whole world. Such models have too many interdependencies that make decoupling and subdividing impossible.”
- NX puts all apps into an apps folder and the libraries into libs.
- each domain is represented by a subfolder.
- everything that is shared across different folders goes into a /shared folder.
- we use prefixes (feature, util, etc) to denote the layer a specific library is a part of.
To reduce the cognitive load, the Nx team recommends categorizing libraries as follows:
- feature: Implements a use case with 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
Please note the separation between smart and dumb components. Smart components within feature libraries are use case-specific. An example is a component that enables a product search.
On the contrary, dumb components do not know the current use case. They receive data via inputs, display it in a specific way, and issue events. Such presentational components “just” help to implement use cases and hence they are reusable. An example is a date-time picker, which is unaware of which use case it supports. Hence, it is available within all use cases dealing with dates.
In addition to this, I also use the following categories:
- shell: For an application that has multiple domains, a shell provides the entry point for a domain
- api: Provides functionalities exposed to other domains
- domain: Domain logic like calculating additional expenses (not used here), validations or facades for use cases, and state management. I will come back to this in the next chapter.
Each category defines a layer in our architecture matrix. Also, each library gets a prefix telling us to which category and hence layer it belongs. This helps to maintain an overview.
Robust architecture requires limits to interactions between libraries. If there were no limits, we would have a heap of intermingled libraries where each change would affect all the other libraries, clearly negatively affecting maintainability.
Based on DDD, we have a few rules for communication between libraries to ensure consistent layering. For example, each library may only access libraries from the same domain or shared libraries.
We also define access restrictions on top of our layers shown in the matrix above. Each layer is only allowed to access the layers below. For instance, a feature library can access UI, domain, and util libraries.
“projects": {
"ui": {
"tags": ["scope:app"]
},
“catalog-api": {
"tags": ["scope:catalog", "type:api", "name:catalog-api"]
},”
“catalog-data-access": {
"tags": ["scope:catalog", "type:data-access"]
},
"shared-util-auth": {
"tags": ["scope:shared", "type:util"]
}
}
To enforce access restrictions, Nx comes with its own linting rules. As usual, we configure these rules within .eslintrc.json.
The following invariants should hold true:
- a lib cannot depend on an app
- an app-specific library cannot depend on a lib from another app (e.g., "safe/" can only depend on libs from "safe/" or shared libs)
- a shared library cannot depend on an app-specific lib (e.g., "common-ui/" cannot depend on "safe/")
- a ui library cannot depend on a feature library or a data-access library.
- a utils library cannot depend on a feature library, data-access library, or component library.
- a data-access library cannot depend on a feature library or a component library.
- a project cannot have circular dependencies.
- a project that lazy loads another project cannot import it directly.
See https://nx.dev/core-features/enforce-project-boundaries#tags
Give a try to @angular-architects/ddd to install some schematics which automate slicing your Nx workspace into domains and layers according to Nrwl's best practices and angular-architects ideas about client-side DDD with Angular.
https://www.npmjs.com/package/@angular-architects/ddd
Tags will be defined in a 2-dimensional matrix, with scopes as vertical columns and types as horizontal rows.
Scope relates to a logical grouping, business use-case, or domain. Examples of scope from our sample application are seatmap, booking, shared, and check-in. They contain libraries that manage a sub-domain of application logic. We recommend using folder structure to denote scope. The following folder structure is an example scope hierarchy used to describe the seatmap feature: shared/ seatmap/ feature/
Here, "shared" and "seatmap" are grouping folders, and feature is a library that is nested two levels deep. This offers a clear indication that this feature belongs to a domain of seatmap which is a sub-domain of shared items. The tag used in this library would be scope:shared, as this is the top-level scope.
- each main app in the repo name.
- core
- shared
Type relates to the contents of the library and indicates its purpose and usage. Examples of types are ui, data-access, and feature. We recommend using prefixes and tags to denote type. We recommend limiting the number of types to only the four described in the sections to follow.
The tag for the seatmap feature library as in the previous example would now be scope:shared,type:feature.
- app
- feature: Developers should consider feature libraries as libraries that implement smart UI (with injected services) for specific business use cases or pages in an application.
- data-access: A data-access library contains services and utilities for interacting with a back-end system. It also includes all the code related to State management.
- util: A utility library contains common utilities and services used by many libraries and applications.
- UI: A UI library contains only presentational components.
- assets
A feature library contains a set of files that configure a business use case or a page in an application.
Most of the components in such a library are smart components that interact with the NgRx Store. This type of library also contains most of the UI logic, form validation code, etc. Feature libraries are almost always app-specific and are often lazy-loaded.
Naming Convention
feature (if nested) or feature-* (e.g., feature-shell).
Data-access libraries contain REST or webSocket services that function as client-side delegate layers to server tier APIs. All files related to State management also reside in a data-access folder (by convention, they can be grouped under a +state folder under src/lib).
Naming Convention
data-access (if nested) or data-access-* (e.g. data-access-seatmap)
A UI library is a collection of related presentational components. There are generally no services injected into these components (all of the data they need should come from Inputs).
Naming Convention
ui (if nested) or ui-* (e.g., ui-buttons)
A utility contains common utilities/services used by many libraries. Often there is no ngModule and the library is simply a collection of utilities or pure functions.
Naming Convention
util (if nested), or util-* (e.g., util-testing)
In our reference structure, the folders libs/booking, libs/check-in, libs/shared, and libs/shared/seatmap are grouping folders. They do not contain anything except other library or grouping folders.
The purpose of these folders is to help with organizing by scope. We recommend grouping libraries together which are (usually) updated together. It helps with minimizing the amount of time a developer spends navigating the folder tree to find the right file.
apps/ booking/ check-in/ libs/ booking/ <---- grouping folder feature-shell/ <---- library
check-in/
feature-shell/
shared/ <---- grouping folder
data-access/ <---- library
seatmap/ <---- grouping folder
data-access/ <---- library
feature-seatmap/ <---- library
One of the main advantages of using a monorepo is that there is more visibility into code that can be reused across many different applications. Shared libraries are a great way to save the developer time and effort by reusing a solution to a common problem.
Let’s consider our reference monorepo. The shared-data-access library contains the code needed to communicate with the back-end (For example, the URL prefix). We know that this would be the same for all libs; therefore, we should place this in the shared lib and properly document it so that all projects can use it instead of writing their own versions.
libs/ booking/ data-access/ <---- app-specific library
shared/
data-access/ <---- shared library
seatmap/
data-access/ <---- shared library
feature-seatmap/ <---- shared library
The README should identify the library’s purpose and outline the public API for the library. The document may also include other details such as:
- code owner
- the library’s usage visualization (dependency-graph)
- the dependency-constraints (which apps or libraries are authorized to USE this library)
The main module or component for a library must contain the full path (relative to libs) within the filename. e.g. the main module for the library libs/booking/feature-destination would have the module filename as booking-feature-destination.module.ts.
This is the way that the CLI creates the modules and we are recommending that we keep this pattern.
plastikaweb (c) 2022-2024