How to programmatically enforce boundaries between applications and libraries in an Nx monorepo

Jurgen Van de Moere
Product & Engineering at Showpad
8 min readFeb 28, 2020

--

In “How to organize and name applications and libraries in an Nx monorepo for immediate team-wide benefits, we learned that organizing libraries by scope and type offers many immediate team-wide benefits so that all team members can:

  • easily find the libraries they need
  • immediately identify what type of code a library contains
  • immediately know whether or not a specific library can be consumed

Having such clear and strategic guidelines in place is awesome.

However, we, developers, are only human and can easily make mistakes.

In this article, we have a look at how we can use and configure Nx’s awesome code analysis tool to make sure that all applications and libraries in our monorepo truly honor our guidelines.

By the end of this article, you will understand:

  • how a well-configured code analysis tool can help us detect human mistakes before they are deployed in production
  • how to configure the Nx code analysis tool
  • how to run the Nx code analysis tool

So let’s get started!

Library scopes and types

Nx’s code analysis tool makes heavy use of library scope and types.

So before we have a look at Nx’s code analysis tool, let’s refresh our minds and have a super quick look at what we learned about library scopes and types in the previous article.

We learned how to organize libraries strategically by scope and type:

where a library scope (scope-1, scope-2) is:

  • a logical grouping to organize libraries semantically
  • represented by a directory

The goal of a scope is to provide insights in what part of the logic a library deals with, so that:

  • all team members can immediately see from the scope whether or not they can consume a library in their application or library
  • the maintainers of the library know exactly which applications and libraries can depend on their library and have full awareness of the impact of their changes when they update a library

To name a scope, we use the following rules:

  • If a library is built specifically for use in one application, its scope is the application name e.g. “windows-desktop”, “windows-mobile”.
  • If a library is built specifically for use in all applications and libraries, its scope is “shared”.
  • If a library is built for use in a subset of applications and libraries, its scope is a grouping name e.g. if a library is built for “windows-desktop” and “windows-mobile” but not for other scopes such as “ios”, then we could define a scope with a grouping name “windows”.

In contrast, a library type (type-1, type-2) is:

  • a logical way to provide a library name with extra semantic value
  • represented by a library name prefix

The Enterprise Angular Monorepo Patterns book defines and recommends the following types:

  • data-access: contains services and utilities for interacting with back-end systems and all code related to state management
  • feature: contain smart UI (with injected services) for specific business use-cases or pages in an application
  • ui: contains only presentational components (that rely only on inputs and outputs)
  • util: contains common utilities and services used by other libraries and applications

In the previous article, we started with the following applications and libraries:

and we ended with the following structure of libraries organized by scope and type:

This strategically organized structure provides us with immediate team-wide benefits.

So what is the problem?

In an ideal world, our guidelines would be known and applied by all developers in the team and no violations would be made against them.

In reality, however, we have no guarantees that all team members understand and apply our guidelines correctly.

Even if we clearly communicate the guidelines within the team, we have no technical safety-net in place that prevents team members from making any unintended violations or mistakes.

For example, if we consume an icon from windows/ui-icons inside web-app, things may technically work and our code may still compile, even if we break the boundary defined by the library scope.

However, when the maintainers of windows/ui-icons make a change, they will safely assume that web-app is not consuming the library. As a result, changes may be introduced that break web-app.

So how do we deal with these situations? How can we be sure that all team members follow the guidelines consistently and stick to the boundaries?

Luckily, Nx has us covered!

Programmatic code analysis

Nx comes with an awesome code analysis tool that allows us to verify programmatically whether all applications and libraries only consume libraries that they have access to.

The tool comes in the form of a TSLint rule that is defined in tslint.json at the root of the monorepo.

The rule has the following format:

"nx-enforce-module-boundaries": [enable, options]

where:

  • enable: boolean, enable or disable the rule

and options is an object of the following type:

{
allow: string[];
depConstraints: [
{
sourceTag: string;
onlyDependOnLibsWithTags: string[];
}
]
}

where:

  • allow: array, whitelist of libraries that can bypass the constraints defined in depConstraints
  • depConstraints: array, dependency constraints we wish to apply to our libraries

By default, Nx comes with the following configuration:

which has the following effect:

  • true: enable the rule
  • allow: empty whitelist, all libraries have to pass the checks in depConstraints, no exceptions allowed
  • depConstraints: make sure all libraries (sourceTag: "*") can only depend on all other libraries (onlyDependOnLibsWithTags: ["*"])

The default configuration thus allows all libraries to depend on all other libraries.

Now that we know where Nx stores its TSLint rule configuration, let’s have a look at how can we update this configuration to enforce the guidelines from our example.

Tags

If we look carefully at the depConstraints key of the options object, we can see that Nx refers to tags:

So what are these tags that Nx is referring to?

Tags are metadata that we can attach to applications and libraries via the nx.json file.

The nx.json file is a JSON file with a key projects that contains an entry for each of the projects defined in our monorepo.

A project can be an application or a library and each project has a key called tags where we can associate one or more tags with a project.

In our example, we have a project for each of our applications and libraries:

This means we can use tags to associate metadata such as scope and type information with projects.

Because tags are primitive strings, the Enterprise Angular Monorepo Patterns book recommends a clever and concise way to use colons in tags to associate scope and type information with projects:

When we apply that strategy to our example, we get:

There are 2 ways to associate tags with an application or library:

  1. By editing nx.json and adding the tags manually to the tags property of your application or library.
  2. By specifying --tags when generating a new application or library:
    ng generate lib windows-ui-icons --tags=scope:windows,type:ui
    which automatically adds the tags to nx.json for us.

Make sure not to use spaces in tags so that your command does not break.

In ng generate lib windows-ui-icons --tags=scope: windows, the space between scope and windows would break the command.

Allright! We covered a lot already:

  • we learned that Nx provides us with a tslint rule nx-enforce-module-boundaries that allows us to define boundaries using tags
  • we learned that we can associate tags with an application or library via nx.json

Let’s apply this knowledge to our example and use tags to enforce constraints between our applications and libraries.

Configuring constraints

In the previous section, we already added tags to our applications and libraries to associate each project with a scope and type:

We can now use these tags to configure the boundaries between our applications and libraries via the nx-enforce-module-boundaries rule in tslint.json.

In Enterprise Angular Monorepo Patterns, the Nrwl team recommends the following constraints:

  1. App-specific libraries cannot depend on libraries from other applications.
    For example: scope:windows-desktop can only depend on scope:windows-desktop and scope:shared.
  2. Shared libraries cannot depend on app-specific libraries.
    For example: scope:shared cannot depend on scope:windows-desktop
  3. Ui libraries cannot depend on feature or data-access libraries.
  4. Util libraries can only depend on other util libraries.
  5. Data-access libraries cannot depend on feature or ui libraries.

For the sake of demonstration, let’s assume we want to apply these recommended constraints to our example applications and libraries.

Let’s start by defining boundaries between our different scopes:

We can read the scope constraints as follows:

  • applications and libraries in scope electron can only consume libraries from scope shared and electron
  • applications and libraries in scope shared can only consume libraries from scope shared
  • applications and libraries in scope web can only consume libraries from scope shared and web
  • applications and libraries in scope windows can only consume libraries from scope shared and windows
  • applications and libraries in scope windows-desktop can only consume libraries from scope shared, windows and windows-desktop
  • applications and libraries in scope windows-mobile can only consume libraries from scope shared, windows and windows-mobile

Next, let’s add additional boundaries between types:

We can read the type constraints as follows:

  • libraries of type data-access can only consume libraries of type data-access and util
  • libraries of type ui can only consume libraries of type ui and util
  • libraries of type util can only consume libraries of type util

When we combine our constraints by scope and type, the nx-enforce-module-boundaries rule in tslint.json looks like this:

Awesome! Now that we have our configuration in place, it’s time to try it out!

Running

To check all applications and libraries, run:

$ ng lint

To check only the applications and libraries that are affected by your current changes, run:

$ npm run affected:lint

As soon as a boundary is violated against, the lint rule will safely report it.

For example, if a file in the web-app scope imports a resource from the windows scope:

then running $ npm run affected:lint immediately detects and reports the issue:

TSLint: A project tagged with “scope:web-app” can only depend on libs tagged with “scope:web-app”, “scope:shared”(nx-enforce-module-boundaries)

If you have access to a CI tool, you can automate this task and never allow boundary violations to be merged in your codebase again. Hurray!

In addition, modern code editors and IDEs such as VS Code and WebStorm are equipped to understand TSLint rules and can report violations right inside your editor so that you get live feedback when a violation is made:

How sweet is that!

Summary

When developing multiple applications and libraries in a monorepo, it can be surprisingly difficult to name libraries in such a way that all team members can easily find them and know immediately whether or not the library can be consumed.

Organizing libraries by scope and type is a mechanism that helps us conquer that challenge and offers many immediate team-wide benefits.

As a safety-net against unintended boundary violations, Nx provides us with an awesome code analysis tool that allows us to verify whether all applications and libraries in our monorepo truly honor our boundaries.

In this article, we learned:

  • what tags are and how they can be used to tag applications and libraries by scope and type via nx.json
  • that Nx provides us with a flexible TSLint rule nx-enforce-module-boundaries to define boundaries between applications and libraries using tags
  • how to translate the recommended constraints from Nrwl’s Enterprise Angular Monorepo Patterns into a TSLint rule
  • that running $ ng lint checks all applications and libraries and that running $ npm run affected:lint checks only the applications and libraries that are affected by our current changes

Armed with this knowledge, you can now safely protect your monorepo from boundary violations and rest assured that no unintended violations creep into your code.

Because a well-defined organization of your monorepo truly matters!

Have a great one!

--

--

Jurgen Van de Moere
Product & Engineering at Showpad

Front-end Architect | Principle Engineer @Showpad | Developer Expert @Google