Packages, Sub-packages, and NPM

Travis Jones
4 min readApr 29, 2021

--

I’ve been working recently with the NX CLI. The layered architecture it supports and its ability to generate publishable NPM libraries from within a given project. This has led me to wanting to build local packages and sub-packages. Without diving too deep into the specifics of NX, let’s look at what working in this architecture looks like, in order to explain my use-case.

Layered Architecture

The general concept of layered architecture is that libraries can be classified into separate levels, where the bottom level contains the most commonly used code or tools, the top (domain) layer containing code and logic describing an aspect of business logic, and the middle layer being a step in between. Within this setup, libraries can only be imported into libraries of the same layer, or higher.

Note: The above description is grossly oversimplified, but will suit for the purposes of this article. If you want something more in-depth, check out Domain-Driven Design by Eric Evans.

Library Imports within NX

NX makes building monorepos easy. Included with it is automatic import path setup. Examples of these import paths (assuming layered architecture is used, and we’re following NX conventions) include:

  • import { x } from '@myorg/{layer prefix}-{name}'
  • import { x } from '@myorg/{group}/{layer prefix}-{name}' — For libraries grouped together in a folder (i.e. ‘shared’).

There are advantages to this library setup, including controlled exports.

Actually Getting On Topic

Returning to the actual subject of this article, NPM packages and sub-packages, I want to generate packages for these libraries, and what I’d really prefer to do is use and import path like @myorg/{layer}/{name} . This looks nice and clean to me. But, there’s a problem: even though NX will set up library imports that follow this convention, this is not a valid NPM package name. Apparently, NPM only allows package names that include one forward slash. If you try to go forward with packing using such a name, it will throw an error explaining it’s an invalid name.

Sub-packages

Despite the above convention not yielding a valid package name, I’ve actually imported code from an NPM package that has a second forward slash (without using custom path setups via Typescript configurations). That being said there are some pretty big implications to it. Let’s look at what the package actually looks like:

my-package
|- package.json "name": "@myorg/my-package"
|- sub-package-1 directory"
|- package.json "name": "@myorg/my-package/sub-package-1"
|- sub-package-2 directory
|- package.json "name": "@myorg/my-package/sub-package-2"

In this package, we have a package.json file in the top directory, then additional package.json files in sub-directories. With this setup, we would not run npm pack on the sub-packages. Instead, you would pack the top-level package and the sub-packages would be available as a matter of course. For example, the main package is the only one listed among the importing project’s dependencies.

... In the package.json"dependencies": {
"@myorg/my-package": "latest"
}
... Then in your codeimport { x } from '@myorg/my-package/sub-package-1';
import { y } from '@myorg/my-package/sub-package-2';

While going this route I would get the naming convention I want, but there are implications to this. Assuming that your project is properly tree-shaken (unused code is discarded during build time), bloat from this approach shouldn’t be a huge cause for concern. Instead, the concern involves being unable to individually upgrade libraries.

Let’s assume that you follow the multi-slash sub-package approach shown above, and that it has multiple sub-packages. It may look something like this:

@myorg/my-package
|- /sub-pack-1 <- gets updated regularly
|- /sub-pack-2 <- rarely gets updated
... other hypothetical sub-packages

Now, let’s say you have an application (that hasn’t been updated in a year), that uses both sub-pack-1 and sub-pack-2 , and suddenly you need to make a crucial change to sub-pack-2. In order to get that crucial update deployed to your application, you would have to build a brand new version of @myorg/my-package, which would include your crucial change in sub-pack-1, as well as a year’s worth of updates in sub-pack-2, which could easily introduce breaking changes, potentially drastically increasing the development and QA work involved.

Now, I’m not saying the above sub-packaging strategy is bad. In actuality, there’s a trade-off to sub-packaging versus individual packaging. Let’s look at the breakdown of it:

Sub-packaging

  • Condenses a potentially long list of libraries into a single dependency
  • Updating one package gives all of its sub-package updates
  • More usable with fewer consuming applications, where all library updates should go to all applications
  • Potentially requires sophisticated VCS (GIT) work to deploy small changes to stale applications
  • Designed in a way that handles fewer scenarios than individual packaging

Individual Packaging

  • Imparts granular control over packages and greater flexibility
  • Longer list of dependencies and versions to manage
  • Updating individual packages is not a huge concern

In the end, individual packaging is what suits my needs (having run into the described scenario under high-pressure situations). So, this means I have to abandon my hopes for a multi-slash import path, leaving me with the following convention: @myorg/{layer}-{name}. To me, the major drawback of this convention is that there’s no visible way of distinguishing between a nested scope and a simple hyphenated package name. Fortunately, while this is deeply disappointing to my sensibilities (having fallen in love with the import paths in NX), it has no practical impact on me in the end.

TL;DR

NPM sub-packaging is possible, but it can limit flexibility, since they cannot be individually packaged or deployed.

--

--