nx-logo

The way Nx is Marketed

Nx is a build system with built-in tooling and advanced CI capabilities. It helps you maintain and scale monorepos, both locally and on CI.

The above is a description of Nx taken directly from the nx.dev homepage.

To me the keywords in this mission-statement-like paragraph are “build system” and “monorepo”. When I first read this, I came away with the idea that it was a way to create and enable monorepos, and automatically handle CI/CD.

This interpretation turned out to be somewhat correct, and somewhat wrong.

Why wrong?

  1. When it comes to building applications, Nx doesn’t actually do the building. What I mean by this is that the framework itself doesn’t know that a project in its monorepo is a NodeJS server, or a React frontend, or a Go application, and then go ahead and build each of these projects. Nx won’t automatically select a pre-automated CI/CD pipeline to handle build tasks for your respective frameworks.

  2. A monorepo is simply a single repository containing multiple distinct projects, with well-defined relationship. Yes, Nx has generators and migrators that create monorepo workspaces, create projects, and also allow us to manage their interdependencies, but to say that Nx is a monorepo tool is severely limiting.

Why correct?

To a certain extent Nx does enable “build” and “monorepos”.

It handles “build” by delegating these to Nx tasks, which run commands/scripts that need to be customised to different use cases. And some of these tasks are encapsulated in plugins tailor made for different use cases.

For example, if you wanted to build a NextJS project, you could generate and build it with an Nx plugin built by the framework authors nrwl that serves that purpose. If you wanted something for a Go application, there is a community plugin for that. The distinction seems semantic, but is actually important for understanding the framework – Nx itself is the coordinator, not the workhorse. And it can coordinate much more than just build tasks.

As for “monorepos”, Nx does handle these innately within the framework. But as mentioned, that’s not all Nx does.

What Nx’s Real Value Is: Conventions and Standardisation

Understanding the value that Nx unlocks can be gleaned from its problem statement:

Setting up a system that works well for a handful of developers and at the same time, easily scales up to an entire organization is hard. This includes setting up low-level build tooling, configuring fast CI, and keeping your codebase healthy, up-to-date, and maintainable.

The problem highlights the difficulty of scaling up from a small team to managing the development environment of large organisations. Why is this a problem? Partly because the proliferation of tools and approaches within an organisation increases as the organisation gets larger, increasing technological complexity.

Let’s imagine

Imagine a badling of ducks cackling and screaming and hurtling in multiple directions, while a hapless farmer tries to rein them all in. This describes the typical engineering environment of a large organisation.

Not that we’re ducks or anything. Though sometimes some of us sure can seem like a close relative – the kind that screeches and dives and swoops down on us at the beach trying to steal our fries… anyway.

According to a publication by BCG, higher digital technology complexities within organisations lengthen time-to-market, increase IT costs, and reduces a company’s competitiveness. Two of the solutions presented tackle this complexity head-on: Application and Data Simplification, and Infrastructure-Technology-Pattern Reduction.

The first tackles the consolidation and elimination of applications to simplify the technology landscape. The second seeks to reduce the number of unique configurations within that landscape.

What has all this go to do with Nx?

Nx actually provides a neat way to tackle this proliferation head on, by allowing standards-oriented developers to use plugins to literally codify rules and conventions for application scaffolding, management and build.

Yes, Nx is a tool that generates a monorepo workspace and manages it. But at its core, this monorepo context enables it to set a multi-project boundary, within which the Nx framework and its plugins can operate to standardise conventions in repo tooling and integration.

Nx is a standardisation tool, enabled by its monorepo context.

Conventions, conventions, conventions

Not following? Let’s use two key features to explain how Nx enables standardisation via codified conventions: generators and migrators.

Generators

Nx generators automate code generation and scaffolding for various capabilities, like creating a new application from scratch.

Let’s scaffold a static site as an example

Let’s use the @nx/web plugin as an example, which is used to generate static websites in vanilla HTML/CSS/JS.

When we run its generator:

npx nx generate @nx/web:application <app-name>

this plugin creates a new application for us within an Nx workspace with a preconfigured setup, and a folder structure that looks like the below:

my-web-app
├── index.html
├── project.json
├── public
│   └── favicon.ico
├── src
│   ├── app
│   │   ├── app.element.css
│   │   ├── app.element.spec.ts
│   │   └── app.element.ts
│   ├── assets
│   ├── main.ts
│   └── styles.css
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.spec.js

Sure, we could handwrite this code ourselves, but that comes with the attendant cost of making mistakes, deviations from guidelines, future onboarding headaches, or a shouting match with one of our teammates. Any of these could cost us in wasted man-hours, or assault charges and jail-time (never seen this before, hope never to).

Instead, we could use nx plugins and their generators to get an out-of-the-box standardised approach, like in the above.

This also comes with the attendant benefits of having the plugin authors already handling some of the heavy lifting for us. Like preparing code optimisation tools, or handling caching concerns.

Peeking inside a generator

Let’s create a generator from scratch to get a better feel of what I mean:

npx create-nx-workspace <workspace-name>   # create nx workspace
cd <workspace-name>                        # cd into workspace
npx nx add @nx/plugin                      # a plugin that generates a plugin
npx nx g @nx/plugin:plugin <plugin-name>   # generate the plugin
npx nx g @nx/plugin:generator              # generate a generator for the plugin
  \ <generator-name> 
  \ --project=<plugin-name>      

After the above, we can look under <plugin-name>/src/generators/<generator-name>/generator.ts. This presents us with the following:

import {
  addProjectConfiguration,
  formatFiles,
  generateFiles,
  Tree,
} from '@nx/devkit';
import * as path from 'path';
import { XyzGeneratorSchema } from './schema';

export async function xyzGenerator(
  tree: Tree,
  options: XyzGeneratorSchema
) {
  const projectRoot = `libs/${options.name}`;
  addProjectConfiguration(tree, options.name, {
    root: projectRoot,
    projectType: 'library',
    sourceRoot: `${projectRoot}/src`,
    targets: {},
  });
  generateFiles(tree, path.join(__dirname, 'files'), projectRoot, options);
  await formatFiles(tree);
}

export default xyzGenerator;

The above (written in TypeScript) provides us several functions and helpers that lets us take the project tree, and modify it into a different form. This we can do by adding or removing files, and modifying their contents. For example, we could write code that takes a tsconfig.json somewhere, and adds a few configuration properties to it.

We can essentially take a certain project structure (a convention) and convert it into another structure (another convention) that enables certain functionality.

Note the description provided by the documentation about generators:

The generator.ts provides an entry point to the generator. The file contains a function that is called to perform manipulations on a tree that represents the file system.

Our generator expects a certain structure (a convention) to output another structure with new files/folders/etc added according to its recipe (another convention). In most plugins, if the expected structure doesn’t exist (a convention violation), it fails.

These generators can be provided by the creators/maintainers of nx (providing framework conventions), the community (providing community-validated conventions), the organisation (providing organisation-driven conventions) or yourself (personal approaches codified into conventions).

And in all the above examples, our conventions are literally codified in code.

Migrations

Just to hit the point home, we can also have a look at migrations. In this case, a quote from the documentation is enough to demonstrate our point:

Nx knows where its configuration files are and can therefore make sure they match the expected format.

Migrations in Nx work because they work with configuration files that have an expected format. Sounds familiar? That’s because this essentially describes a convention.

Aside from this, migrations work exactly the same way generators do, just with special versioning.

Standardisation is a Good Thing

“But i don’t want to be locked into someone else’s conventions.”

The above would be a valid point if you build your applications from the ground up, but most of us don’t.

Take for example NextJS or CRA. Yes, we can build a React application with nothing but html and js files (and by golly i have!), but most of us don’t. If you use NextJS or CRA you are buying into someone else’s established conventions, however much they make it customisable.

Fantastic and beautiful tools like Prettier and Lint are also standardisation tools. At their core, they enable a fluid interaction at the project level, by building in assumptions and syntax conventions into automatic checkers.

We can even take this to the extreme and make the statement that all code is convention. Programming languages really are a codified approaches to interact with machines after all.

Nx just takes the above ideas and blows them up into a massive scale at the multi-project level.

Opacity Problem and its Solution

Something that plagues projects that solve problems through standardisation (read: convention over configuration), is the abundant application of magic. When this happens, cause and effect is obscured from users, because configuration files toggle and adjusts a solution’s behavior in indirect ways.

This leads to confusion about how certain things work. This tripped me up a lot for example, when working on nx tasks. Tasks are usually found in package.json scripts or nx.json/project.json targets, but there are also implicit tasks present via plugins, something Nx calls inferred tasks.

“Implicit” when applied to software, is a term that usually scares me. It fact, it scared some programmers enough that they went ahead to build a class of programming languages called functional languages that specifically do everything possible to eliminate implicitness. Though this sometimes created their own mini-hellscapes. Looking at you, monads.

Implicitness leads to opacity. And in my case it led to utter confusion about why some tasks were running that were not defined in obvious places.

Traditionally there are 2 ways to deal with the problem of implicitness: very good documentation (not always attainable) or an uncanny patience for trial-and-error (also not always attainable).

There is however a third and specific Nx way: the Project Details View.

Run the following command to bring up this view:

npx nx show project <project-name> --web

It should look something like below:

example-project-view

The above is a view of the monorepo as Nx sees it, taken from a hosted example project, and gives a lot of helpful context and information. This includes dependency graphs, projects, targets (tasks) that can be run, and others.

For my specific problem described above of understanding which tasks would run when a target is selected, we can click on any of the targets above, and it will show an executor header. This essentially tells us what would execute a task. In our example, the deploy target is being run by the nx:run-commands executor, and the serve target is being run by the @nx/webpack:dev-server executor.

Some Tips

With our above understanding, a few tips come to mind when dealing with Nx monorepos:

  • Don’t touch configuration files unless absolutely necessary and you know what you are doing. If a configuration change is necessary, handle these through plugin migrations to share the benefits across different projects and monorepo stacks.
  • Leverage existing plugins developed by Nx or the community, because these would encapsulate best practices around build, deployment and other non-business logic related tasks. This enables you to focus on what you do best, building good products and solutions for your customers.
  • Be clear about the boundaries of responsibility. Establish what capabilities Nx and associated plugins/generators/migrators should handle in your projects, and what you should handle and scope under business logic to handle on your own.

Conclusion

Thinking of Nx as a standardisation tool enabled within a monorepo context really helped me wrap my mind around its value proposition. I hope it helps you too.