---
title: Workspaces
source: 'https://academia.sh/en/courses/javascript-ecosystem/workspaces'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:46+00:00'
license: 'CC BY-SA 4.0'
---

# Workspaces

Keeping multiple packages in a single repository, the workspace declaration, resolving local packages through links, topological build order, and versioning strategies.

In the previous lessons, every package came from outside: requested with a range in
the manifest, installed into a directory, resolved from there. For packages a team
writes itself, this cycle is heavy — every fix made in a library has to be published
before it can be tried in the application that uses it.

The measurement library has reached this point. The formatting helper was pulled out
into its own package, and a report tool that presents the measurement result was
added. All three are the same team's product and change together. This lesson covers
how the three are kept in a single repository.

## The Multi-Package Repository

The arrangement that keeps multiple packages in a single version-control repository is
called a **monorepo**. The problems it solves are concrete:

**Split changes.** Adding a parameter to a library and updating the tools that use it
is a multi-step operation across separate repositories: publish, wait, update the
range, install. In a single repository, this is a single commit.

**Version lag.** In separate repositories, there is always a gap between the
library's latest state and the state the application is using. In a single repository,
this gap is zero.

**Duplicated configuration.** Linter rules, formatting settings, and the test setup
are kept in one place.

There is a cost too. The repository grows; which packages are affected by each change
has to be computed; test time tends toward the sum of all packages; versioning and
release decisions get more complex. Not worth paying if the packages do not change
together. The rule of thumb: a single repository suits packages updated together in
the same change; separate repositories suit packages that evolve independently.

## The Workspace Declaration

The manifest at the root says which subdirectories are packages, and marks itself as
not to be published:

```json
{
  "name": "measurement-repo",
  "private": true,
  "workspaces": ["packages/*"]
}
```

The `private` field is a safeguard: the root directory is not a library, and
publishing it by accident is not wanted. The `workspaces` field tells the installation
tool to treat every directory matching the listed pattern as a **workspace**.

The three packages in the repository are these:

```json
{ "name": "format-helper", "version": "1.4.0", "type": "module", "main": "index.mjs" }
```

```json
{
  "name": "text-measurer",
  "version": "0.1.0",
  "type": "module",
  "exports": { ".": "./src/index.mjs" },
  "dependencies": { "format-helper": "^1.4.0" }
}
```

```json
{
  "name": "report-tool",
  "version": "0.2.0",
  "type": "module",
  "main": "index.mjs",
  "dependencies": { "text-measurer": "^0.1.0" }
}
```

The point worth noting: the dependencies use no special notation stating they are
local. Package `text-measurer` requests `format-helper` by its bare name and a range,
the same as any package coming from outside. Locality is a decision made by
installation, not the manifest: if the installer sees the requested name is a
workspace in the repository and its version fits the range, it does not go to the
registry.

## How the Links Are Installed

A local package is placed not by copying it into the tree, but by a symbolic link:

```
$ for l in node_modules/*; do printf '%s -> %s\n' "$l" "$(readlink "$l")"; done
node_modules/format-helper -> ../packages/format
node_modules/report-tool -> ../packages/report
node_modules/text-measurer -> ../packages/measurer
```

The symbolic link concept was defined in the links lesson of the Introduction to Linux
course: an entry that points at a target without being the target itself. The
consequence here is decisive. The resolution rule looks for the name in
`node_modules` and does not check whether the entry it finds is a link; the directory
it finds is the source tree itself. A change made in the source is visible to the
consumer without a reinstall.

```javascript
// file: packages/format/index.mjs
export function decimal(value, digits = 2) {
  return value.toFixed(digits);
}
```

```javascript
// file: packages/measurer/src/index.mjs -- segmentation and statistics modules sit in the same directory
import { decimal } from 'format-helper';
import { splitSentences, splitWords } from './segmentation.mjs';
import { averageLength, longestWord } from './statistics.mjs';

export function measure(text) {
  const words = splitWords(text);
  return {
    wordCount: words.length,
    sentenceCount: splitSentences(text).length,
    averageLength: decimal(averageLength(words)),
    longest: longestWord(words),
  };
}
```

```javascript
// file: packages/report/index.mjs
import { measure } from 'text-measurer';

export function report(text) {
  const m = measure(text);
  return `${m.wordCount} words / ${m.sentenceCount} sentences / avg. ${m.averageLength}`;
}
```

```javascript
// file: try.mjs -- repo root
import { report } from 'report-tool';

console.log(report('A module carries its own scope. A script runs in global scope.'));
```

```
$ node try.mjs
12 words / 2 sentences / avg. 4.08
```

Three packages found each other by their bare names without any of them being
published. This arrangement has a silent trap: an import that works locally may not
work once published. The directory reached through the link is the entire source tree;
the published archive carries only the files listed in the manifest. If the exports
map or file list is missing something, the error only shows up after publishing —
which is why checking the archive's contents before publishing has to be a habit in
multi-package repositories.

## Build Order

Since the packages depend on one another, bulk operations performed on them — build,
test, publish — cannot run in an arbitrary order. Testing `report-tool` requires
`text-measurer` to be ready first.

The correct order is the topological sort of the dependency graph. This sort,
introduced in the Data Structures course, applies directly here:

```javascript
// file: order.mjs -- repo root, reads the packages/ directory
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';

const root = 'packages';
const packages = new Map();
for (const dir of (await readdir(root)).sort()) {
  const manifest = JSON.parse(await readFile(join(root, dir, 'package.json'), 'utf8'));
  packages.set(manifest.name, Object.keys(manifest.dependencies ?? {}));
}

// Topological sort by depth-first search: dependencies are published first.
const result = [];
const state = new Map();
function visit(name) {
  if (state.get(name) === 'done') return;
  if (state.get(name) === 'visiting') throw new Error(`cycle: ${name}`);
  state.set(name, 'visiting');
  for (const dependency of packages.get(name) ?? []) {
    if (packages.has(dependency)) visit(dependency);
  }
  state.set(name, 'done');
  result.push(name);
}
for (const name of packages.keys()) visit(name);

console.log(result.join(' -> '));
```

```
$ node order.mjs
format-helper -> text-measurer -> report-tool
```

Two details of the script matter. Dependencies not present in the repository are
skipped: the order of external packages does not concern us, they are already
installed. Second, visit state has three values; reaching a "visiting" node again
means a cycle, and an error is raised. Unlike a circular dependency between modules, one
between packages cannot be resolved at all.

The same graph answers a second question too: which packages are affected by a change
made in one package? The answer is found by walking the graph in the reverse
direction, and it lets only the affected packages be tested. This is the method that
keeps test time manageable in large repositories.

## Versioning Strategy

The versions of packages in the repository are managed in two ways.

**Unified versioning.** All packages carry the same number and are published
together. The publish decision is single and easy to track; in exchange, a package
that has not changed at all also gets a new version, forcing consumers into an
unnecessary upgrade.

**Independent versioning.** Each package carries its own number and is published
only when it changes — honest toward the consumer; in exchange, it requires tracking
which package is at which version and keeping in-repo ranges up to date.

The choice depends on who the consumer is. If the packages are only used within the
repository, unified versioning makes work easier. If they are published outward,
independent versioning is the only choice faithful to the contract established in the
previous lesson: a version number carries meaning only when it announces a real
change.

## Summary

- A multi-package repository lets packages that change together be updated in a
  single commit and share a single configuration; the cost goes unpaid if the packages
  evolve independently.
- The workspace list in the root manifest states which directories are packages;
  dependencies use no special notation to mark themselves as local.
- Local packages are placed in the tree with a symbolic link; a change made in the
  source is visible without requiring a reinstall.
- Because the entire source tree is reachable through the link, files missing from the
  published archive are only noticed after publishing.
- Bulk operations on packages run in the topological order of the dependency graph;
  walking in the reverse direction gives the packages affected by a change.

## Next Step

Most of the packages in the tree were not chosen directly; they are there because they
are some other package's dependency. These packages run code too, and they can execute
scripts during installation. The next lesson measures the size of this transitive
mass, and covers the defensive sides of dependency selection, locking, and
verification.
