Fine-grained Architecture with Nx

Achieving the fastest possible builds with Nx Caching and fine-grained libraries

cover-image
portrait-image

Introduction

It is fairly common to have a granular structure when using Nx because of the module-boundary rules that can be enforced with the linter. This has been one of the main selling points of Nx. Another, yet underrated, selling point for these fine-grained libs is the ability to build them incrementally. This can lead to a significant speedup in build times.

Usually, we would use something like the Enterprise Monorepo Angular Patterns to structure our application into domain layers, whereas each layer consists of one or more libraries. This is a great way to structure your application, especially because it enforces the module-boundary rules and allows for a clear separation of concerns and incremental builds with the Nx cache.
If we want to go a step further and get even more performance out of the Nx cache, we can split up our libraries even further into tinier libraries. How tiny? Just one element per library!
You want to create a directive? Okay, then you need to create a whole library for it. But don't worry, although it sounds like a lot of overhead, it is actually not that bad, because we can use the Nx CLI to generate the libraries for us. We still get a whole lot more configuration files, but all of them are auto-generated, which means there is no extra work for us.


Let's take a step back and talk about the Nx cache for a second.

Nx Caching

Hashing Algorithm
Hashing Algorithm

Nx, by default, is already caching locally into a .cache folder in the node_modules . For the caching it is using a hashing algorithm that takes the node version, projects, task, global configurations into consideration to generate a unique hash value. That means that if you were to add only a single space to a file, the calculated hash value would look entirely different, causing a new build. Hence, it is of big importance to always use a pre-commit hook for prettifying the code using something like husky or git hooks. By default, this cache is stored in the node_modules and therefore owned by the local developer and is cleared whenever the node_modules are removed.
Note, that you could use a distributed cache instead, to share the cache between developers. For this you could easily connect to the Nx Cloud or use a custom remote cache by adapting the local task runner.

Affected Libs
Affected Libs

Now that we know how the Nx cache works, let's talk about how we can use it to our advantage. Imagine that you changed a little thing in a component, does it really make sense to rebuild the entire application? Not really, right?
In order to avoid having to rebuild the entire application, we can create buildable libraries which then are cached individually. If a library has not changed, it will not be rebuilt and drawn from the hot cache. , it should be clear that the more libraries we have, the more fine-grained our cache will be. The more likely it is that we can draw more from the cache.

Fine-grained Libs

Whereas in common DDD-approaches we would have one or more libraries for each domain-layer, which may have multiple components, services, directives, etc., in a fine-grained approach we would have one library per component, service, directive, etc. This does not contradict DDD because we could still follow the same domain-layer structure, but with more libraries. In this blog post, I will not take DDD into consideration and will only focus on the basic fine-grained approach.

Libs
Libraries

app - Application
route - Contains a route constant
smart-component - Contains a smart component, which is able to inject facades
dumb-component - Contains a dumb component
facade - Contains a facade, which is hdies state management and data services
data-service - Contains a data service, which is responsible for fetching data from the backend
directive - Contains a directive
pipe - Contains a pipe
util - Contains a utility function
model - Contains a model


With the @nx/enforce-module-boundaries rule we can enforce that only certain libraries can be imported by other libraries. Therefore, it is possible to setup architectural rules such that only facade can be imported by smart-component and is the only library allowed to import data-service. Also, dumb-component cannot be imported by smart-component.


A typical task graph of a small fine-grained architecture would look like this:

Task Graph
Task Graph

Note that this is only a small example. In a real-world application, the task graph would be much bigger. Anyways, we can see the order of the build tasks and reason about the affected libraries by visualizing the task graph.

Result

🔥 Maximum build-time optimization
🔥 Smallest possible module-boundaries
🔥 Architectural dependency constraint rules
🔥 Fine-grained dependency graph

results
5s for 10 cached, 1 rebuilt

GitHub Repository

Comments