Implicit Libraries
Project Crystal
Since version 18 (and actually a little before that), Nx has been able to infer tasks based on the project structure. This means that Nx plugins can automagically add new targets (e.g. implicitly add a test
target with the right configuration when it detects a Vitest configuration file). This is called Project Crystal.
Implicit Libraries
Implicit Libraries are a way to leverage Project Crystal to make an Nx library nothing more than a new folder in your workspace with an index.ts
file at its root.
Here is an example of what a workspace looks like with Implicit Libraries compared to Explicit Libraries.
- Explicit Libs
- Implicit Libs
libs
└── web
├── cart
│ └── ui
│ ├── .eslintrc.json
│ ├── README.md
│ ├── project.json
│ ├── src
│ │ ├── index.ts
│ │ └── lib
│ │ ├── cart.spec.ts
│ │ └── cart.ts
│ ├── tsconfig.json
│ ├── tsconfig.lib.json
│ ├── tsconfig.spec.json
│ ├── vite.config.ts
│ └── vitest.config.ts
└── catalog
└── ui
├── .eslintrc.json
├── README.md
├── project.json
├── src
│ ├── index.ts
│ └── lib
│ ├── catalog.spec.ts
│ └── catalog.ts
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
├── vite.config.ts
└── vitest.config.ts
libs
└── web
├── .eslintrc.json
├── cart
│ └── ui
│ ├── README.md
│ ├── cart.spec.ts
│ ├── cart.ts
│ └── index.ts
├── catalog
│ └── ui
│ ├── README.md
│ ├── catalog.spec.ts
│ ├── catalog.ts
│ └── index.ts
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
└── vite.config.ts
The Boilerplate Problem
A common drawback when creating libraries is the boilerplate. Even though they are generally generated and taken care of by Nx (using generators and migrations), it can clutter the workspace and add some cognitive load.
Here is a typical non-buildable library structure:
libs/web/catalog/ui
├── .eslintrc.json
├── README.md
├── project.json
├── src
│ ├── index.ts
│ ├── lib
│ │ ├── my-lib.spec.ts
│ │ └── my-lib.ts
│ └── test-setup.ts
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
└── vite.config.ts
Step 1: Shared Configuration Files
You will notice that many libraries share similar configuration files. The similarities are often per platform, but there could be other groupings.
Interestingly, most tools provide options that allow us to target specific folders and files (e.g. eslint [dir]
, vitest --root [dir]
, ...). This means that we could provide configurations that are shared by multiple libraries but use different options to target specific libraries.
For example, if you group your libraries per platform, you could move the configuration files to the common platform folder:
libs/web
├── .eslintrc.json 👈
├── catalog/ui
│ ├── README.md
│ ├── project.json
│ └── src
│ ├── index.ts
│ └── lib
│ ├── catalog.spec.ts
│ └── catalog.ts
├── cart/ui
│ ├── README.md
│ ├── project.json
│ └── src
│ ├── index.ts
│ └── lib
│ ├── cart.spec.ts
│ └── cart.ts
├── tsconfig.json 👈
├── tsconfig.lib.json 👈
├── tsconfig.spec.json 👈
└── vite.config.ts 👈
You will need to mainly adjust the paths in the configuration files. Here are some examples:
- "extends": ["../../../.eslintrc.json"],
+ "extends": ["../../.eslintrc.json"]
- "extends": "../../../tsconfig.base.json",
+ "extends": "../../tsconfig.base.json",
- "exclude": ["./vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
+ "exclude": ["./vite.config.ts", "**/*.spec.ts", "**/*.test.ts"]
project.json
is foundIf you enabled the @nx/eslint
plugin (plugins: ["@nx/eslint/plugin"]
in nx.json
), the lint
target will be added to both libraries even if there is no eslint configuration file in the library.
While we could add a test
target to each library as shown below, this would defeat the purpose of implicit libraries.
"targets": {
"test": {
"command": "vitest",
"options": {
"cwd": "{projectRoot}",
"root": "."
}
}
}
Step 2: Implicit Library Inference
Thanks to Project Crystal, not only can we infer the targets we need (e.g. test
) but we can also infer the libraries themselves. This means that we can remove all libraries' project configurations (i.e. project.json
) and infer them dynamically.
First, you can prepare and simplify the workspace by:
- removing
project.json
files from your libraries, - flattening the libraries content,
- updating the
tsconfig.base.json
to point to the right paths (e.g.libs/my-lib/src/index.ts
=>libs/my-lib/index.ts
).
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
- "@marmicode/web-catalog-search-ui": ["libs/web/catalog/search-ui/src/index.ts"],
+ "@marmicode/web-catalog-search-ui": ["libs/web/catalog/search-ui/index.ts"],
}
}
}
The workspace would look something like this:
libs/web
├── .eslintrc.json
├── catalog/ui
│ ├── README.md
│ ├── index.ts 👈
│ ├── catalog.spec.ts 👈
│ └── catalog.ts 👈
├── cart/ui
│ ├── README.md
│ ├── index.ts 👈
│ ├── cart.spec.ts 👈
│ └── cart.ts 👈
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
└── vite.config.ts
Now, you can create a workspace plugin that will add the libraries to the Nx graph.
import { CreateNodesV2 } from '@nx/devkit';
export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
const [libs, platform, scope, name] = indexPath.split('/');
const projectRoot = `${libs}/${platform}/${scope}/${name}`;
const projectName = `${platform}-${scope}-${name}`;
return [
/* This is used by Nx to track which matching file was used by the plugin
* It is shown in the project detail web view. */
indexPath,
{
projects: {
/* This will add a project to the Nx graph for the detected library. */
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
},
},
},
];
});
},
];
Once you enable the plugin by adding it to your plugins in the nx.json
file, you should see the libraries in the Nx graph (nx graph
).
{
"plugins": ["./tools/plugins/implicit-libs.ts"]
}
$ nx show projects
web-catalog-ui
web-cart-ui
Step 3: Debug the Plugin
The first time you try to debug the plugin, you might be surprised not to see the logs you produce in the console, nor to hit the breakpoints you set in your favorite IDE. This happens because the plugin runs in a separate background process (the Nx daemon).
While you can still find the logs in Nx logs file (i.e. .nx/workspace-data/d/daemon.log
), it is not very convinient. In addition to that, the plugin is not executed on every command as the graph is cached.
The easiest way to debug the plugin is by disabling the Nx daemon and enabling some additional Nx performance logging if needed.
NX_DAEMON=false NX_PERF_LOGGING=true nx show projects
Step 4: Add Targets to the Implicit Libraries
As we removed the project.json
files, Nx plugins will not add any targets to the libraries. We have to adapt our plugin to add the targets we need.
export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
// ...
return [
indexPath,
{
projects: {
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
targets: {
lint: {
command: 'eslint .',
options: {
cwd: projectRoot,
},
},
test: {
command: 'vitest',
options: {
cwd: projectRoot,
root: '.',
},
},
},
},
},
},
];
});
},
];
Note that while we could technically hide the configuration files (e.g., .eslintrc.json
, tsconfig*.json
, vite.config.ts
, etc.), doing so would break IDE support and other tools or plugins that rely on these files.
Step 5: Configure Caching
While the example above is a good start, it is still missing the caching configuration, but here is the good news: you can also infer the caching configuration.
export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
// ...
return [
indexPath,
{
projects: {
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
targets: {
lint: {
command: 'eslint .',
// ...
cache: true,
inputs: [
'default',
'^default',
'{workspaceRoot}/.eslintrc.json',
`{workspaceRoot}/${libs}/${platform}/.eslintrc.json`,
'{workspaceRoot}/tools/eslint-rules/**/*',
{
externalDependencies: ['eslint'],
},
],
outputs: ['{options.outputFile}'],
},
test: {
command: 'vitest',
// ...
cache: true,
inputs: [
'default',
'^production',
{
externalDependencies: ['vitest'],
},
{
env: 'CI',
},
],
outputs: [
`{workspaceRoot}/coverage/${libs}/${platform}/${name}`,
],
},
},
},
},
},
];
});
},
];
As of today, there is no simple way of reusing Nx plugins logic in your own plugins.
A quick workaround is to give a look a the inferred project configuration before moving to implicit libraries (or using a sample Nx workspace):
nx show project my-lib --json | jq .targets.test
Otherwise, Nx plugins source code is a good source of inspiration (e.g. @nx/eslint or @nx/vite).
Step 6: Tag the Implicit Libraries
Finally, you can let the plugin tag the libraries based on the file structure convention.
export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
// ...
return [
indexPath,
{
projects: {
[projectRoot]: {
// ...
tags: [`platform:${platform}`, `scope:${scope}`, `type:${type}`],
},
},
},
];
});
},
];
Step 7: Add Libraries
You can now add new libraries by creating a new folder with an index.ts
file following the file structure convention implemented in the Implicit Libraries plugin.
libs/
└── web 👈 platform
└── catalog 👈 scope
└── search-feature 👈 name-type (or just type)
└── index.ts
Then you can add the new library to the tsconfig.base.json
paths.
{
"compilerOptions": {
"paths": {
...
+ "@marmicode/web-catalog-search-feature": ["libs/web/catalog/search-feature/index.ts"]
}
}
}
While Implicit Libraries make library generators less useful, implementing a generator could still be useful to:
- make sure that libraries are created with the right structure and categories,
- update the
tsconfig.base.json
paths, - update the
.eslintrc.json
with new boundaries when a library is using a new scope.
Step 8: Enjoy the Implicit Libraries
You can now enjoy your simplified workspace and verify the results using Nx console or by running the following command:
nx show project web-cart-ui --web
Full Plugin Example
Here is the full plugin example:
import { CreateNodesV2 } from '@nx/devkit';
export const createNodesV2: CreateNodesV2 = [
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
const [libs, platform, scope, name] = indexPath.split('/');
const projectRoot = `${libs}/${platform}/${scope}/${name}`;
const projectName = `${platform}-${scope}-${name}`;
const nameParts = name.split('-');
const type = nameParts.at(-1);
return [
indexPath,
{
projects: {
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
tags: [`platform:${platform}`, `scope:${scope}`, `type:${type}`],
targets: {
lint: {
command: 'eslint .',
options: {
cwd: projectRoot,
},
metadata: { technologies: ['eslint'] },
cache: true,
inputs: [
'default',
'^default',
'{workspaceRoot}/.eslintrc.json',
`{workspaceRoot}/${libs}/${platform}/.eslintrc.json`,
'{workspaceRoot}/tools/eslint-rules/**/*',
{
externalDependencies: ['eslint'],
},
],
outputs: ['{options.outputFile}'],
},
test: {
command: 'vitest',
metadata: { technologies: ['vitest'] },
options: {
cwd: projectRoot,
root: '.',
},
cache: true,
inputs: [
'default',
'^production',
{
externalDependencies: ['vitest'],
},
{
env: 'CI',
},
],
outputs: [
`{workspaceRoot}/coverage/${libs}/${platform}/${name}`,
],
},
},
},
},
},
];
});
},
];
Note that this is a simplified example. You might need to adjust it to your needs.
👉 You can find a more complete example on the Cookbook Demos repository. 👈
When to use Implicit Libraries?
While implicit libraries can be a powerful way to:
- 👍 simplify your workspace,
- 👍 get more control over the configuration,
- 👍 share configuration files,
- 👍 ensure that the workspace is highly aligned with the architecture style and the file structure convention,
they come with some drawbacks like:
- 🫤 having to implement plugins,
- 🫤 and losing Nx builtin plugins inference.
That is why our general recommendation is to mainly use Implicit Libraries for non-buildable libraries.
From what we observed, the amount of boilerplate generated by libraries tends to discourage teams from creating additional libraries. Implicit Libraries can help teams create more libraries and easily maintain them, leading to a more modular architecture.