development-monorepo-pnpm skill: initial import
Full-stack pnpm + Turborepo monorepo skill, independent of the separate backend and frontend monorepo skills it draws on. Repository shape is split four ways at the top level: applications-backend, applications-frontend, packages-backend, packages-frontend — so a path states which runtime a file ends up in. Defaults: NestJS for backend services, React with IBM Carbon Design for browser applications; the project's stated architecture overrides both. Covers workspace globs, the Turborepo pipeline, filtered validation, blast-radius checks, Module Federation, turbo prune image builds, and Helm. Documents one sanctioned cross-boundary dependency: API contract types owned by the backend, imported type-only by the frontend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# development-monorepo-pnpm
|
||||
|
||||
Agent Skill for full-stack monorepos built on **pnpm workspaces** and **Turborepo**, where the
|
||||
front/back split is expressed in the top-level directory names:
|
||||
|
||||
```text
|
||||
applications-backend/ # deployable services — NestJS by default
|
||||
applications-frontend/ # deployable browser apps — React + IBM Carbon by default
|
||||
packages-backend/ # libraries consumed by services
|
||||
packages-frontend/ # libraries consumed by browser apps
|
||||
```
|
||||
|
||||
Framework defaults are defaults, not mandates — a project's stated architecture always wins.
|
||||
|
||||
The skill is self-contained: it covers both halves of the stack, from workspace globs and the
|
||||
Turborepo pipeline through to container images and Helm charts.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
development-monorepo-pnpm/
|
||||
├── SKILL.md # Entry point — repo shape, defaults, shared rules
|
||||
├── references/
|
||||
│ ├── applications-backend.md # NestJS layout, config, ports, health, service image
|
||||
│ ├── applications-frontend.md # React + Carbon, Module Federation, nginx image
|
||||
│ ├── packages.md # Which root, package tiers, public API, changesets
|
||||
│ ├── workspace-and-tooling.md # Globs, turbo.json, root scripts, overrides
|
||||
│ └── containers-and-helm.md # turbo prune images, charts, values, secrets
|
||||
└── assets/
|
||||
└── example-shared-package/ # Minimal shared TypeScript package
|
||||
```
|
||||
|
||||
## The rule worth knowing up front
|
||||
|
||||
Frontend workspaces must not import from `packages-backend/*` and vice versa — with one
|
||||
exception: **API contract types**. Contracts are owned by the service that defines them, so
|
||||
they live in `packages-backend/<domain>-contracts`, export types only, and are imported by
|
||||
frontend consumers with `import type`. A value import from the same package pulls server code
|
||||
into the browser bundle, and nothing in the build will flag it.
|
||||
|
||||
See [SKILL.md](SKILL.md).
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
name: development-monorepo-pnpm
|
||||
description: >
|
||||
Create, review, refactor, debug, and maintain full-stack monorepos built on pnpm workspaces
|
||||
and Turborepo, split four ways — applications-backend, applications-frontend,
|
||||
packages-backend, packages-frontend. Backend services default to NestJS; browser
|
||||
applications default to React with IBM Carbon Design, unless the project architecture states
|
||||
otherwise. Use when adding or changing a service, controller, module, React page, component
|
||||
or hook; assigning ports and API base paths; sharing API contract types across the
|
||||
front/back boundary; wiring Module Federation; editing shared eslint or typescript presets;
|
||||
cutting changesets; editing Dockerfiles, Helm charts, or Turborepo tasks; running filtered
|
||||
validation or blast-radius checks; or fixing workspace, dependency, lockfile, or
|
||||
build-pipeline problems — even when the user only names their repository instead of saying
|
||||
"monorepo".
|
||||
license: Proprietary
|
||||
metadata:
|
||||
author: workspace-skills-code-agent
|
||||
version: "1.0"
|
||||
spec: agentskills.io/specification
|
||||
compatibility: Requires pnpm (via corepack) and Node. Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, and other compatible agent environments
|
||||
---
|
||||
|
||||
# Full-Stack pnpm + Turborepo Monorepo
|
||||
|
||||
Work rules for monorepos holding **both** backend services and browser applications, using
|
||||
**pnpm workspaces** for dependency resolution and **Turborepo** for task orchestration.
|
||||
|
||||
The defining choice in this layout is that the front/back split is expressed in the
|
||||
**directory names themselves**, not in a nested convention. Four top-level workspace roots,
|
||||
each with one job.
|
||||
|
||||
## Scope routing
|
||||
|
||||
Pick the reference that matches the change; do not load all of them.
|
||||
|
||||
| The change touches | Read |
|
||||
|---|---|
|
||||
| A service under `applications-backend/*` — modules, controllers, providers, config | `references/applications-backend.md` |
|
||||
| An app under `applications-frontend/*` — components, routing, Carbon, Module Federation | `references/applications-frontend.md` |
|
||||
| Anything under `packages-backend/*` or `packages-frontend/*` — exports, presets, changesets | `references/packages.md` |
|
||||
| `pnpm-workspace.yaml`, `turbo.json`, root scripts, formatting, dependency overrides | `references/workspace-and-tooling.md` |
|
||||
| Dockerfiles, images, Helm charts, values, deploy automation | `references/containers-and-helm.md` |
|
||||
|
||||
## 1. Canonical repository shape
|
||||
|
||||
```text
|
||||
<repo>/
|
||||
├── applications-backend/ # deployable services — NestJS by default
|
||||
│ └── <service-name>/
|
||||
├── applications-frontend/ # deployable browser apps — React + Carbon by default
|
||||
│ └── <app-name>/
|
||||
├── packages-backend/ # libraries consumed by services
|
||||
│ └── <package-name>/
|
||||
├── packages-frontend/ # libraries consumed by browser apps
|
||||
│ └── <package-name>/
|
||||
├── helm-charts/<chart-name>/ # deployment assets
|
||||
├── .scripts/ # shell modules: docker, images, helm, pipelines
|
||||
├── package.json # root scripts + packageManager + engines + overrides
|
||||
├── pnpm-workspace.yaml # the four globs below
|
||||
├── turbo.json # task pipeline
|
||||
├── pnpm-lock.yaml
|
||||
├── .prettierrc # single formatting authority
|
||||
└── Taskfile.yml # thin delegator — includes .scripts/ modules only
|
||||
```
|
||||
|
||||
```yaml
|
||||
# pnpm-workspace.yaml
|
||||
packages:
|
||||
- "applications-backend/*"
|
||||
- "applications-frontend/*"
|
||||
- "packages-backend/*"
|
||||
- "packages-frontend/*"
|
||||
```
|
||||
|
||||
Applications sit **directly** under their root — `applications-backend/<name>`, not
|
||||
`applications-backend/<name>/core`. The four-way split already gives each side its own
|
||||
namespace, so no extra nesting layer is needed.
|
||||
|
||||
**A workspace exists only if a glob matches it.** Creating a directory is not enough: confirm
|
||||
the glob, then run `pnpm install`. A directory outside these four roots is not part of the
|
||||
build graph, and Turborepo will silently ignore it.
|
||||
|
||||
### Why the split is in the directory name
|
||||
|
||||
One glance at a path tells you which runtime a file ends up in. That matters because the two
|
||||
sides have genuinely different constraints — a browser bundle ships to untrusted clients and
|
||||
pays for every kilobyte, a service does not; a service holds credentials, a bundle must never.
|
||||
When both live under one `packages/` root, the only thing standing between a secret-handling
|
||||
utility and a browser bundle is someone reading the import path carefully.
|
||||
|
||||
## 2. Default stacks
|
||||
|
||||
| Layer | Default | Applies unless |
|
||||
|---|---|---|
|
||||
| `applications-backend/*` | **NestJS** — modules, controllers, providers, DI | the project architecture specifies another framework |
|
||||
| `applications-frontend/*` | **React** with **IBM Carbon Design** (`@carbon/react`) | the project architecture specifies another framework or design system |
|
||||
|
||||
These are defaults, not mandates. **The project's stated architecture always wins** — read the
|
||||
paired code-agent repository's `memory-bank/` (§9) and any architecture decision record
|
||||
*before* scaffolding. If the architecture is silent, use the defaults above and record the
|
||||
choice; if it disagrees, follow it and do not "correct" it toward these defaults.
|
||||
|
||||
Do not mix design systems inside one frontend application. Carbon components and a second
|
||||
component library in the same app produce two type scales, two grid systems, and a token set
|
||||
that fights itself.
|
||||
|
||||
## 3. The one sanctioned cross-boundary dependency
|
||||
|
||||
Frontend workspaces must not import from `packages-backend/*`, and backend workspaces must not
|
||||
import from `packages-frontend/*` — with exactly one exception:
|
||||
|
||||
**API contract types.** The service that defines an endpoint owns its contract, so contract
|
||||
packages live under `packages-backend/<domain>-contracts` and expose **types only** — no
|
||||
runtime code, no dependencies, no side effects. Frontend consumers import them type-only:
|
||||
|
||||
```ts
|
||||
import type { UserDto } from "@<scope>/user-contracts";
|
||||
```
|
||||
|
||||
`import type` is erased at compile time, so nothing from the backend package reaches the
|
||||
browser bundle. A plain `import` from the same package does reach it — that is the failure
|
||||
this rule exists to prevent, and it will not show up as a type error.
|
||||
|
||||
A contract package that acquires a runtime dependency has stopped being a contract package.
|
||||
Split it before it ships anything into a bundle.
|
||||
|
||||
## 4. Tooling baseline
|
||||
|
||||
Root `package.json` pins the contract for every contributor and CI runner: `packageManager`
|
||||
(exact pnpm version, read by corepack), `engines.node`, `pnpm.overrides` for security pins,
|
||||
and root scripts that always delegate to Turborepo — never to a single workspace.
|
||||
|
||||
```bash
|
||||
pnpm build # turbo run build
|
||||
pnpm dev # turbo run dev
|
||||
pnpm lint # turbo run lint
|
||||
pnpm check-types # turbo run check-types
|
||||
pnpm format # prettier --write
|
||||
pnpm publish-packages # build:ci + lint, then changeset version && changeset publish
|
||||
pnpm prepush # turbo build --filter=...[HEAD^] — only what the branch touched
|
||||
```
|
||||
|
||||
`build` depends on `^build`, `check-types`, and `lint`, so a type error or lint error is a
|
||||
build failure for every downstream workspace, not a warning. Full pipeline, cache outputs, and
|
||||
override rules → `references/workspace-and-tooling.md`.
|
||||
|
||||
## 5. Before making changes
|
||||
|
||||
1. Identify the exact workspace path, and therefore which of the four roots you are in.
|
||||
2. Read that workspace's `package.json` — scripts, dependencies, whether it is publishable.
|
||||
3. Inspect its `tsconfig.json`, lint config, build config, and source layout before adding.
|
||||
4. Map what it consumes from shared packages, and what consumes it.
|
||||
5. For Module Federation changes, map the host↔remote contract before editing either side.
|
||||
6. Read the paired code-agent repository's `rules/` and `memory-bank/` when one exists (§9) —
|
||||
it carries architecture decisions the code does not.
|
||||
|
||||
```bash
|
||||
find applications-backend applications-frontend -maxdepth 2 -name package.json -print
|
||||
find packages-backend packages-frontend -maxdepth 2 -name package.json -print
|
||||
pnpm list --depth 0
|
||||
task --list
|
||||
```
|
||||
|
||||
## 6. Implementation rules
|
||||
|
||||
### Workspace boundaries
|
||||
|
||||
- Application logic stays inside its own workspace.
|
||||
- Promote into a shared package only when **more than one** application needs it *and* the
|
||||
abstraction has stopped changing. Config presets are the exception — shared from day one.
|
||||
Premature promotion is the most expensive mistake here: every consumer inherits the churn.
|
||||
- A shared package goes in the root matching its **consumers**, never its author.
|
||||
- Never cross a workspace boundary with a relative import (`../../other-app/src`). Depend on
|
||||
the workspace package, or the Module Federation contract, or not at all.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Every workspace extends a shared config from the `typescript-config` preset. Do not restate
|
||||
`compilerOptions` locally beyond `outDir`, `rootDir`, `include`, `exclude`.
|
||||
- `check-types` is `tsc --noEmit`, named identically in every workspace so Turborepo can run
|
||||
it across the graph.
|
||||
- Keep types explicit at public boundaries; avoid broad `any`.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Add a dependency to the **smallest** workspace that needs it.
|
||||
- Root dependencies are monorepo tooling only — turbo, prettier, husky, changesets,
|
||||
typescript. Nothing an application imports at runtime.
|
||||
- Inter-workspace dependencies use `workspace:*`.
|
||||
- Run `pnpm install` after every `package.json` edit and commit the lockfile change with the
|
||||
edit that caused it.
|
||||
- Never remove a `pnpm.overrides` entry without a security review — removing one silently
|
||||
reintroduces a vulnerable transitive version and nothing in the build complains.
|
||||
|
||||
### Formatting
|
||||
|
||||
Root `.prettierrc` is the single authority. Do not add per-package Prettier config; a second
|
||||
config produces churn diffs every time someone formats a file from the other side.
|
||||
|
||||
## 7. Validation
|
||||
|
||||
Validate the narrowest scope covering the change, then widen if it touched shared code.
|
||||
|
||||
```bash
|
||||
# Single workspace
|
||||
pnpm check-types --filter="@<scope>/<name>"
|
||||
pnpm lint --filter="@<scope>/<name>"
|
||||
pnpm build --filter="@<scope>/<name>"
|
||||
|
||||
# Shared package or cross-workspace change — unfiltered, plus each consumer
|
||||
pnpm check-types && pnpm lint && pnpm build
|
||||
pnpm build --filter="@<scope>/<consumer>"
|
||||
|
||||
# Only what the branch changed
|
||||
pnpm prepush
|
||||
```
|
||||
|
||||
Module Federation changes additionally require **host and remote running together** — a green
|
||||
build on each side separately proves nothing about a runtime contract:
|
||||
|
||||
```bash
|
||||
pnpm dev --filter="@<scope>/<host>"
|
||||
pnpm dev --filter="@<scope>/<remote>"
|
||||
```
|
||||
|
||||
Record the commands run and their outcome in the task record (§9). "It builds", without the
|
||||
filter you used, is not a validation result.
|
||||
|
||||
## 8. Blast-radius checks
|
||||
|
||||
Before changing anything another workspace can observe — an exported symbol, a REST base path,
|
||||
a port, an environment variable name, an MFE `exposes` key:
|
||||
|
||||
1. Enumerate the surface.
|
||||
2. Search every consumer across all four roots.
|
||||
3. Decide explicitly whether the change is backward-compatible.
|
||||
4. If it is not: update consumers in the same change, or ship a compatible wrapper first.
|
||||
5. Validate each affected consumer with its own `--filter` build.
|
||||
|
||||
```bash
|
||||
grep -Rn "from '@<scope>/<package>'" applications-backend applications-frontend \
|
||||
packages-backend packages-frontend
|
||||
grep -Rn "api/<name>" applications-frontend # base-path callers
|
||||
```
|
||||
|
||||
A port or base path change is never local: it also lands in the Helm chart values, the gateway
|
||||
routing, and every frontend caller. See `references/containers-and-helm.md`.
|
||||
|
||||
## 9. Durable records
|
||||
|
||||
Monorepos in this family pair with a `<repo>-code-agent/` repository holding `rules/`,
|
||||
`memory-bank/`, and `tasks/`. Update `memory-bank/` when durable facts change — application
|
||||
inventory, ports, base paths, MFE topology, chosen frameworks and design system, chart
|
||||
structure. Create a task record under `tasks/` for substantial work.
|
||||
|
||||
Never place task records inside the target repository unless explicitly asked.
|
||||
|
||||
## 10. Common mistakes to avoid
|
||||
|
||||
- Importing a `packages-backend/*` package from a frontend workspace with a value import
|
||||
instead of `import type` — it silently pulls server code into the browser bundle.
|
||||
- Putting a shared package in the root that matches its author rather than its consumers.
|
||||
- Adding a workspace outside the four roots, or without updating `pnpm-workspace.yaml`.
|
||||
- Re-introducing a `core/` nesting layer under an application directory.
|
||||
- Changing a service port or base path without updating Helm values, gateway routing, and
|
||||
every caller.
|
||||
- Changing an MFE `exposes` key, remote name, or remote URL on one side only.
|
||||
- Removing a `pnpm.overrides` entry without a security review.
|
||||
- Adding a root-level dependency for a need local to one workspace.
|
||||
- Skipping `--filter` validation for the exact workspace you edited.
|
||||
- Mixing a second component library into a Carbon application.
|
||||
- Overriding the project's stated architecture because it differs from the defaults in §2.
|
||||
- Adding tasks directly to root `Taskfile.yml` instead of a `.scripts/` module.
|
||||
- Editing generated artifacts (`dist/`, rendered manifests, lockfiles) by hand.
|
||||
- Committing `.env`, `key.pem`, `cert.pem`, registry tokens, or `.npmrc` secrets — including
|
||||
into memory-bank or task documentation.
|
||||
|
||||
## Reference files
|
||||
|
||||
| File | Contents | Read when |
|
||||
|---|---|---|
|
||||
| `references/applications-backend.md` | NestJS service layout, modules/controllers/providers, config and env, ports and base paths, health checks, service Dockerfile | Touching `applications-backend/*` |
|
||||
| `references/applications-frontend.md` | React app layout, IBM Carbon setup and theming, routing, state, Module Federation wiring and failure modes, nginx image | Touching `applications-frontend/*` |
|
||||
| `references/packages.md` | Package tiers per root, contract packages, public-API safety, export and dependency rules, changesets and publishing | Touching `packages-backend/*` or `packages-frontend/*` |
|
||||
| `references/workspace-and-tooling.md` | Workspace globs, `turbo.json` pipeline and caching, root scripts, Prettier, `pnpm.overrides`, lockfile discipline | Touching root config or the task pipeline |
|
||||
| `references/containers-and-helm.md` | `turbo prune` image builds for both sides, chart layout, values and secrets, port/base-path propagation | Touching Dockerfiles, images, or charts |
|
||||
| `assets/example-shared-package/` | Minimal shared TypeScript package — ESM, barrel export, shared tsconfig and eslint | Scaffolding a package under either packages root |
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@ca/eslint-config/base"],
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
# example-shared-package — `@ca/example-utils`
|
||||
|
||||
Complete minimal shared TypeScript package for a pnpm + Turborepo monorepo. Copy it as the
|
||||
starting point for a new package and rename the scope to match your repository.
|
||||
|
||||
It goes under **`packages-backend/`** or **`packages-frontend/`** depending on who consumes
|
||||
it — the root matches the consumers, never the author. See `references/packages.md` §1.
|
||||
|
||||
The package itself is environment-neutral: no Node built-ins, no DOM APIs. That is what makes
|
||||
it a safe template for either root. Adding `fs`, `path`, or `crypto` makes it backend-only;
|
||||
adding DOM APIs makes it frontend-only.
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
packages-backend/example-utils/ # or packages-frontend/example-utils/
|
||||
├── .eslintrc.cjs
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── index.ts # Public barrel — re-exports types and utils
|
||||
├── types.ts # Shared TypeScript types
|
||||
└── example-utils.ts # Utility functions
|
||||
```
|
||||
|
||||
## Key conventions
|
||||
|
||||
- `"type": "module"` with ESM output under `dist/`.
|
||||
- `exports` maps the package root to `dist/index.js` and `dist/index.d.ts`.
|
||||
- `tsconfig.json` extends the shared `typescript-config` preset — only `outDir` and `rootDir`
|
||||
are local.
|
||||
- `.eslintrc.cjs` extends the shared `eslint-config` preset.
|
||||
- Internal imports carry the `.js` extension — required for ESM TypeScript.
|
||||
- `files: ["dist"]` keeps sources out of the published payload.
|
||||
- Workspace dependencies use `workspace:*`.
|
||||
- `src/index.ts` is the entire public API; nothing else is importable by consumers.
|
||||
- Script names (`build`, `check-types`, `lint`) match the tasks in `turbo.json`, or Turborepo
|
||||
skips the workspace silently.
|
||||
|
||||
## Contract packages
|
||||
|
||||
A package that shares API types across the front/back boundary is a variant of this template:
|
||||
name it `<domain>-contracts`, place it under `packages-backend/`, export **types only**, and
|
||||
keep it free of runtime dependencies. Frontend consumers import it with `import type`.
|
||||
|
||||
## Validation commands
|
||||
|
||||
```bash
|
||||
pnpm check-types --filter="@ca/example-utils"
|
||||
pnpm lint --filter="@ca/example-utils"
|
||||
pnpm build --filter="@ca/example-utils"
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@ca/example-utils",
|
||||
"version": "1.0.0",
|
||||
"description": "Generic example shared utility package for CogArchHub",
|
||||
"author": "IBM",
|
||||
"license": "IBM copyright",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"registry": "https://nexus.edst.ibm.com/repository/Cognitive_Architect/"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ca/eslint-config": "workspace:*",
|
||||
"@ca/typescript-config": "workspace:*",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ExampleItem, ExampleResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Wraps a value in an ExampleResult with an ISO timestamp.
|
||||
*/
|
||||
export const wrapResult = <T>(data: T): ExampleResult<T> => ({
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds an item by id from a list.
|
||||
* Returns undefined when not found.
|
||||
*/
|
||||
export const findById = (
|
||||
items: ExampleItem[],
|
||||
id: string,
|
||||
): ExampleItem | undefined => items.find((item) => item.id === id);
|
||||
|
||||
/**
|
||||
* Converts an ExampleItem to a display string.
|
||||
*/
|
||||
export const toDisplayString = (item: ExampleItem): string =>
|
||||
`[${item.id}] ${item.name}: ${item.value}`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types.js";
|
||||
export * from "./example-utils.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ExampleItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ExampleResult<T> = {
|
||||
data: T;
|
||||
timestamp: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@ca/typescript-config/base",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# Backend Applications
|
||||
|
||||
Everything under `applications-backend/*`: NestJS service layout, configuration, ports and
|
||||
base paths, health checks, and the service container image.
|
||||
|
||||
Read this when adding a service, adding an endpoint to one, changing configuration or
|
||||
environment variables, assigning a port or base path, or debugging a service that will not
|
||||
start.
|
||||
|
||||
Default framework: **NestJS**. If the project architecture specifies otherwise, follow the
|
||||
architecture — the layout rules below that are framework-neutral still apply.
|
||||
|
||||
---
|
||||
|
||||
## 1. Service workspace layout
|
||||
|
||||
```text
|
||||
applications-backend/<service-name>/
|
||||
├── src/
|
||||
│ ├── main.ts # bootstrap — the only file that binds a port
|
||||
│ ├── app.module.ts # root module, imports feature modules
|
||||
│ ├── config/
|
||||
│ │ ├── configuration.ts # typed config factory, reads process.env once
|
||||
│ │ └── validation.ts # schema; process exits if the env is invalid
|
||||
│ ├── health/ # liveness and readiness endpoints
|
||||
│ └── <feature>/
|
||||
│ ├── <feature>.module.ts
|
||||
│ ├── <feature>.controller.ts # HTTP surface only
|
||||
│ ├── <feature>.service.ts # business logic
|
||||
│ ├── dto/ # request/response shapes
|
||||
│ └── entities/
|
||||
├── test/
|
||||
├── Dockerfile
|
||||
├── nest-cli.json
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── tsconfig.build.json
|
||||
```
|
||||
|
||||
One responsibility per layer, and the boundary is enforceable by review:
|
||||
|
||||
| Layer | Holds | Never holds |
|
||||
|---|---|---|
|
||||
| `*.controller.ts` | routing, param binding, response shape | business rules, direct DB access |
|
||||
| `*.service.ts` | business logic, orchestration | HTTP concerns, `@Res()`, status codes |
|
||||
| `dto/` | validated request/response shapes | logic |
|
||||
| `entities/` | persistence models | HTTP or transport concerns |
|
||||
|
||||
A controller that reaches past its service into a repository is the most common drift in this
|
||||
layout, and it is invisible until you try to reuse the logic from a queue consumer or a cron
|
||||
job and discover it only exists inside an HTTP handler.
|
||||
|
||||
## 2. Configuration and environment
|
||||
|
||||
Read `process.env` in **one** place — the config factory — and inject typed config everywhere
|
||||
else. Scattered `process.env` reads make it impossible to know a service's full input surface,
|
||||
which is exactly what you need when writing the Helm values.
|
||||
|
||||
```ts
|
||||
// src/config/configuration.ts
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT ?? "3000", 10),
|
||||
basePath: process.env.API_BASE_PATH ?? "/api/<service-name>",
|
||||
database: { url: process.env.DATABASE_URL },
|
||||
});
|
||||
```
|
||||
|
||||
Validate at startup and **fail fast**. A service that boots with a missing variable and fails
|
||||
on the first request that needs it turns a config error into a production incident, and the
|
||||
stack trace points at the wrong place.
|
||||
|
||||
Every variable added here must also be added to the Helm values and the service's documented
|
||||
env surface — see `references/containers-and-helm.md`.
|
||||
|
||||
## 3. Ports and base paths
|
||||
|
||||
Both are **cross-cutting contracts**, not local settings:
|
||||
|
||||
- The port appears in `main.ts`, the Dockerfile `EXPOSE`, the Helm values, and the service's
|
||||
Kubernetes Service definition.
|
||||
- The base path appears in `main.ts` (`app.setGlobalPrefix()`), the gateway or ingress routing,
|
||||
and every frontend caller.
|
||||
|
||||
Assign a port from the repository's documented range and record it in the paired code-agent
|
||||
`memory-bank/` — that record is what stops the next service from colliding with it. Changing
|
||||
either value is a blast-radius change (SKILL.md §8).
|
||||
|
||||
```ts
|
||||
// src/main.ts
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix(config.basePath);
|
||||
app.enableCors({ origin: config.corsOrigins });
|
||||
await app.listen(config.port);
|
||||
```
|
||||
|
||||
## 4. Health checks
|
||||
|
||||
Expose **two** endpoints, and keep them different:
|
||||
|
||||
- **Liveness** — is the process alive? No dependency checks. A liveness probe that checks the
|
||||
database restarts a healthy service every time the database blips, turning a partial outage
|
||||
into a total one.
|
||||
- **Readiness** — can it serve traffic now? Checks dependencies it genuinely needs.
|
||||
|
||||
## 5. Consuming shared packages
|
||||
|
||||
Backend services consume `packages-backend/*` with `workspace:*`. They must never import from
|
||||
`packages-frontend/*`.
|
||||
|
||||
A service that defines an API contract publishes it as `packages-backend/<domain>-contracts`,
|
||||
types only, so frontend applications can import it type-only — see SKILL.md §3. Adding a
|
||||
runtime dependency to a contract package breaks that guarantee for every frontend consumer.
|
||||
|
||||
## 6. Container image
|
||||
|
||||
Multi-stage build using `turbo prune` to produce a minimal snapshot of just this service and
|
||||
its workspace dependencies. Pruning is what stops an unrelated workspace change from
|
||||
invalidating this image's layers.
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine AS pruner
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune --scope=@<scope>/<service> --docker
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo run build --filter=@<scope>/<service>
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=builder --chown=app:app /app .
|
||||
USER app
|
||||
EXPOSE <port>
|
||||
CMD ["node", "applications-backend/<service>/dist/main.js"]
|
||||
```
|
||||
|
||||
Points that matter:
|
||||
|
||||
- `out/json/` is copied and installed **before** `out/full/` so dependency installation caches
|
||||
independently of source changes.
|
||||
- `--frozen-lockfile` makes the build fail rather than silently resolve different versions.
|
||||
- Run as a non-root user. The default is root, and nothing warns you.
|
||||
|
||||
## 7. Validation
|
||||
|
||||
```bash
|
||||
pnpm check-types --filter="@<scope>/<service>"
|
||||
pnpm lint --filter="@<scope>/<service>"
|
||||
pnpm build --filter="@<scope>/<service>"
|
||||
pnpm dev --filter="@<scope>/<service>" # it must actually boot
|
||||
|
||||
curl -s localhost:<port>/<basePath>/health | jq .
|
||||
```
|
||||
|
||||
A build that passes proves the types line up; only starting the service proves the config
|
||||
validation and port binding do.
|
||||
|
||||
## 8. Mistakes to avoid
|
||||
|
||||
- Reading `process.env` outside the config factory.
|
||||
- Booting successfully with invalid configuration instead of failing fast.
|
||||
- A liveness probe that checks dependencies.
|
||||
- Business logic in a controller.
|
||||
- Importing from `packages-frontend/*`.
|
||||
- Giving a contract package a runtime dependency.
|
||||
- Changing a port or base path without updating Helm values, gateway routing, and callers.
|
||||
- Hardcoding a base path string in a frontend caller instead of deriving it from the contract.
|
||||
- Running the container as root.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Frontend Applications
|
||||
|
||||
Everything under `applications-frontend/*`: React application layout, IBM Carbon Design setup,
|
||||
Module Federation wiring, and the nginx container image.
|
||||
|
||||
Read this when adding an application, a page, a component or a hook; theming or styling with
|
||||
Carbon; wiring a host to a remote; or debugging a runtime module-loading failure.
|
||||
|
||||
Default stack: **React** with **IBM Carbon Design** (`@carbon/react`). If the project
|
||||
architecture specifies otherwise, follow the architecture — the layout, federation, and image
|
||||
rules below remain framework-neutral.
|
||||
|
||||
---
|
||||
|
||||
## 1. Application workspace layout
|
||||
|
||||
```text
|
||||
applications-frontend/<app-name>/
|
||||
├── src/
|
||||
│ ├── index.tsx # entry — mounts React, imports Carbon styles
|
||||
│ ├── bootstrap.tsx # async boundary, required when federated (§4)
|
||||
│ ├── App.tsx
|
||||
│ ├── pages/<Page>/
|
||||
│ ├── components/<Component>/ # app-local components only
|
||||
│ ├── hooks/
|
||||
│ ├── api/ # typed clients; imports contract types type-only
|
||||
│ └── styles/
|
||||
│ └── _carbon.scss # single Carbon import + theme configuration
|
||||
├── public/
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── vite.config.ts | webpack.config.cjs # webpack when Module Federation is used
|
||||
```
|
||||
|
||||
A component belongs in `packages-frontend/*` only when a **second** application needs it and
|
||||
its API has settled. Until then it stays app-local — a shared component that is still changing
|
||||
imposes its churn on every consumer.
|
||||
|
||||
## 2. IBM Carbon Design
|
||||
|
||||
Install `@carbon/react` in the application workspace, never at the root.
|
||||
|
||||
Import Carbon styles **once**, in one SCSS entry point. Importing them per-component
|
||||
multiplies bundle size and produces unpredictable cascade order:
|
||||
|
||||
```scss
|
||||
// src/styles/_carbon.scss
|
||||
@use "@carbon/react" with (
|
||||
$font-path: "@ibm/plex"
|
||||
);
|
||||
```
|
||||
|
||||
Rules that keep a Carbon application coherent:
|
||||
|
||||
- **Use Carbon components before writing your own.** A hand-rolled button or modal loses
|
||||
Carbon's keyboard handling, focus management, and ARIA wiring — accessibility work that is
|
||||
already done and easy to get wrong.
|
||||
- **Theme with Carbon tokens, never raw hex.** `$background`, `$text-primary`, `$layer-01`.
|
||||
A raw colour is invisible to theme switching and breaks the moment someone enables dark mode.
|
||||
- **Use the Carbon grid** (`Grid`, `Column`) rather than ad-hoc flexbox for page structure, so
|
||||
breakpoints match the rest of the system.
|
||||
- **Never mix in a second component library.** Two design systems in one app means two type
|
||||
scales, two grids, and tokens that fight each other.
|
||||
- Follow Carbon's spacing scale (`$spacing-01` … `$spacing-13`) instead of arbitrary pixels.
|
||||
|
||||
Theme selection belongs at the application root via Carbon's `Theme` component, so a switch
|
||||
propagates to every component without per-component overrides.
|
||||
|
||||
## 3. Talking to backend services
|
||||
|
||||
API clients live in `src/api/`. Import contract types **type-only** from the owning backend
|
||||
package — a value import pulls server code into the browser bundle and nothing in the build
|
||||
will flag it (SKILL.md §3):
|
||||
|
||||
```ts
|
||||
import type { UserDto } from "@<scope>/user-contracts";
|
||||
|
||||
export async function fetchUser(id: string): Promise<UserDto> {
|
||||
const res = await fetch(`${BASE_PATH}/users/${id}`);
|
||||
if (!res.ok) throw new Error(`fetchUser failed: ${res.status}`);
|
||||
return res.json() as Promise<UserDto>;
|
||||
}
|
||||
```
|
||||
|
||||
Base paths come from configuration, never hardcoded per call site — when a service's base path
|
||||
changes, one constant should move, not twenty fetch calls.
|
||||
|
||||
## 4. Module Federation
|
||||
|
||||
Used when applications are composed at runtime: a **host** loads **remotes** over HTTP. Both
|
||||
are built and deployed independently, so **nothing catches a mismatch at compile time**. The
|
||||
contract has three parts, and all three must agree:
|
||||
|
||||
1. the remote's URL and its `remoteEntry.js` filename,
|
||||
2. the exact keys in the remote's `exposes`, matched by the host's imports,
|
||||
3. the shared-dependency singleton settings on **both** sides.
|
||||
|
||||
```js
|
||||
// remote — webpack.config.cjs
|
||||
new ModuleFederationPlugin({
|
||||
name: "remoteApp",
|
||||
filename: "remoteEntry.js",
|
||||
exposes: { "./Widget": "./src/components/Widget" },
|
||||
shared: { react: { singleton: true, requiredVersion: deps.react },
|
||||
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
|
||||
});
|
||||
|
||||
// host — webpack.config.cjs
|
||||
new ModuleFederationPlugin({
|
||||
name: "hostApp",
|
||||
remotes: { remoteApp: "remoteApp@https://<host>/remoteEntry.js" },
|
||||
shared: { react: { singleton: true, requiredVersion: deps.react },
|
||||
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
|
||||
});
|
||||
```
|
||||
|
||||
**The async bootstrap is mandatory.** `src/index.tsx` must do nothing but
|
||||
`import("./bootstrap")`. Federated modules resolve asynchronously; importing React directly in
|
||||
the entry file evaluates it before the shared scope is initialised, and you get two React
|
||||
copies with hook errors that look nothing like a configuration problem.
|
||||
|
||||
Failure modes and what they actually mean:
|
||||
|
||||
| Symptom | Cause |
|
||||
|---|---|
|
||||
| `Shared module is not available for eager consumption` | missing async bootstrap |
|
||||
| `Invalid hook call` / two React copies | `singleton: true` missing on one side |
|
||||
| `ScriptExternalLoadError` | wrong remote URL, or the remote is not deployed |
|
||||
| Host builds, blank page at runtime | `exposes` key renamed on the remote only |
|
||||
|
||||
Changing any part of the contract is a breaking change for the other side (SKILL.md §8).
|
||||
|
||||
## 5. Container image
|
||||
|
||||
Build with `turbo prune`, then serve the static output from nginx:
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine AS pruner
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune --scope=@<scope>/<app> --docker
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo run build --filter=@<scope>/<app>
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
COPY --from=builder /app/applications-frontend/<app>/dist /usr/share/nginx/html
|
||||
COPY applications-frontend/<app>/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 8080
|
||||
```
|
||||
|
||||
`nginx.conf` must route unknown paths back to `index.html`, or a client-side route will 404 on
|
||||
refresh and deep links will not work:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
Anything baked in at build time is public. Build-time environment variables are compiled into
|
||||
the bundle — never put a secret in one.
|
||||
|
||||
## 6. Validation
|
||||
|
||||
```bash
|
||||
pnpm check-types --filter="@<scope>/<app>"
|
||||
pnpm lint --filter="@<scope>/<app>"
|
||||
pnpm build --filter="@<scope>/<app>"
|
||||
```
|
||||
|
||||
Federated applications additionally require host and remote **running together** — separate
|
||||
green builds prove nothing about a runtime contract:
|
||||
|
||||
```bash
|
||||
pnpm dev --filter="@<scope>/<remote>"
|
||||
pnpm dev --filter="@<scope>/<host>"
|
||||
```
|
||||
|
||||
Then load the host and confirm the remote's component actually renders, with a clean console.
|
||||
|
||||
## 7. Mistakes to avoid
|
||||
|
||||
- Importing backend contract types with a value import instead of `import type`.
|
||||
- Importing Carbon styles in more than one place.
|
||||
- Raw hex colours or arbitrary pixel spacing instead of Carbon tokens and the spacing scale.
|
||||
- Hand-rolling a component Carbon already provides.
|
||||
- Mixing a second component library into a Carbon application.
|
||||
- Omitting the async bootstrap in a federated application.
|
||||
- Changing an `exposes` key, remote name, or remote URL on one side only.
|
||||
- Setting `singleton: true` on one side of a shared dependency but not the other.
|
||||
- Missing the `try_files` fallback in `nginx.conf`.
|
||||
- Baking a secret into a build-time environment variable.
|
||||
- Promoting an app-local component into `packages-frontend/*` before a second consumer exists.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Containers and Helm
|
||||
|
||||
Building images for both sides of the monorepo, and the Helm assets that deploy them.
|
||||
|
||||
Read this when editing a Dockerfile, adding a chart, wiring an image tag, changing values or
|
||||
secrets, or propagating a port or base-path change into deployment.
|
||||
|
||||
---
|
||||
|
||||
## 1. `turbo prune` is what makes image builds sane
|
||||
|
||||
Both sides build the same way: prune the monorepo to one workspace and its dependencies,
|
||||
install, build, then copy the result into a minimal runtime image.
|
||||
|
||||
```bash
|
||||
turbo prune --scope=@<scope>/<workspace> --docker
|
||||
```
|
||||
|
||||
This produces `out/json/` (package manifests plus the lockfile) and `out/full/` (source).
|
||||
Copying and installing `out/json/` **before** `out/full/` is the whole point: dependency
|
||||
installation caches on manifests alone, so editing source does not reinstall, and an unrelated
|
||||
workspace's change does not invalidate this image at all.
|
||||
|
||||
Without pruning, every image rebuilds on any change anywhere in the repository.
|
||||
|
||||
## 2. The two runtime shapes
|
||||
|
||||
| | backend | frontend |
|
||||
|---|---|---|
|
||||
| Runtime stage | `node:22-alpine` | `nginx:alpine` |
|
||||
| Ships | compiled server + `node_modules` | static assets only |
|
||||
| Serves | the Node process | nginx |
|
||||
| Needs `try_files` fallback | no | **yes** |
|
||||
| Runtime env vars | yes — read at startup | **no** — baked in at build time |
|
||||
|
||||
That last row is the one that causes incidents. A frontend build inlines its environment
|
||||
variables into the bundle, so they are public and fixed at build time. **A secret in a frontend
|
||||
build argument is a published secret.** Anything genuinely per-environment on the frontend must
|
||||
be fetched at runtime from an endpoint, not baked in.
|
||||
|
||||
Full Dockerfiles: `references/applications-backend.md` §6 and
|
||||
`references/applications-frontend.md` §5.
|
||||
|
||||
## 3. Chart layout
|
||||
|
||||
```text
|
||||
helm-charts/<chart-name>/
|
||||
├── Chart.yaml
|
||||
├── values.yaml # defaults — never environment-specific secrets
|
||||
├── values-<env>.yaml # per-environment overrides
|
||||
└── templates/
|
||||
├── deployment.yaml
|
||||
├── service.yaml
|
||||
├── ingress.yaml
|
||||
├── configmap.yaml
|
||||
└── _helpers.tpl
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Never hardcode an image tag in a template.** It comes from values, supplied by the
|
||||
pipeline. A hardcoded tag makes every environment deploy whatever was current when someone
|
||||
last edited the chart.
|
||||
- Ports and base paths in values must match the service's actual configuration (§5).
|
||||
- Resource requests and limits are set for every container. A container without them competes
|
||||
unboundedly with everything else on the node.
|
||||
- Liveness and readiness probes point at the service's two distinct health endpoints — see
|
||||
`references/applications-backend.md` §4.
|
||||
|
||||
## 4. Secrets
|
||||
|
||||
- **No secret values in `values.yaml`, `values-<env>.yaml`, templates, or the repository.**
|
||||
- Reference an existing Kubernetes `Secret` by name, or use an external secrets provider that
|
||||
materialises it in-cluster.
|
||||
- A base64 string in a committed manifest is encoding, not encryption — treat it as plaintext.
|
||||
- Never log a secret's value from a container's startup path.
|
||||
|
||||
## 5. Port and base-path propagation
|
||||
|
||||
Changing a service's port or base path touches, at minimum:
|
||||
|
||||
1. the service's own config (`main.ts`, config factory),
|
||||
2. the Dockerfile `EXPOSE`,
|
||||
3. the chart's values and `service.yaml`,
|
||||
4. ingress or gateway routing,
|
||||
5. every frontend caller,
|
||||
6. the paired code-agent `memory-bank/` record.
|
||||
|
||||
Miss any one and the failure appears at runtime, in a different component from the one you
|
||||
edited. Treat it as a blast-radius change (SKILL.md §8) and update all six in the same change.
|
||||
|
||||
## 6. Validation
|
||||
|
||||
```bash
|
||||
helm lint helm-charts/<chart-name>
|
||||
helm template <release> helm-charts/<chart-name> -f helm-charts/<chart-name>/values-<env>.yaml
|
||||
|
||||
# what would actually change in the cluster
|
||||
helm diff upgrade <release> helm-charts/<chart-name> -f values-<env>.yaml
|
||||
```
|
||||
|
||||
`helm lint` checks structure, not intent — a chart that lints cleanly can still point at the
|
||||
wrong port. Always read the rendered output for the values that matter: image tag, port, base
|
||||
path, probe paths, resource limits.
|
||||
|
||||
Verify an image locally before shipping it:
|
||||
|
||||
```bash
|
||||
docker build -f applications-backend/<service>/Dockerfile -t <service>:local .
|
||||
docker run --rm -p <port>:<port> <service>:local
|
||||
```
|
||||
|
||||
Build from the **repository root** — the Dockerfile's `COPY . .` expects the whole monorepo as
|
||||
context, because `turbo prune` runs inside the image.
|
||||
|
||||
## 7. Mistakes to avoid
|
||||
|
||||
- Building an image without `turbo prune`.
|
||||
- Copying `out/full/` before installing from `out/json/`, losing all dependency-layer caching.
|
||||
- Running a container as root.
|
||||
- A secret in a frontend build argument or any committed values file.
|
||||
- A hardcoded image tag in a template.
|
||||
- Missing `try_files` in the frontend nginx config.
|
||||
- Containers with no resource requests or limits.
|
||||
- A liveness probe that checks dependencies.
|
||||
- Changing a port or base path in fewer than all six places in §5.
|
||||
- Building the image from the workspace directory instead of the repository root.
|
||||
- Editing rendered manifests instead of the chart that produces them.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Shared Packages
|
||||
|
||||
Everything under `packages-backend/*` and `packages-frontend/*`: which root a package belongs
|
||||
in, package tiers, public-API safety, and changesets publishing.
|
||||
|
||||
Read this when creating a shared package, changing one's exports or dependencies, deciding
|
||||
where a package belongs, or cutting a release.
|
||||
|
||||
---
|
||||
|
||||
## 1. Choosing the root
|
||||
|
||||
**A package lives in the root that matches its consumers, never its author.** A utility
|
||||
extracted from a service but used only by browser applications belongs in
|
||||
`packages-frontend/*`.
|
||||
|
||||
| Consumed by | Root |
|
||||
|---|---|
|
||||
| backend services only | `packages-backend/*` |
|
||||
| browser applications only | `packages-frontend/*` |
|
||||
| both — API contract types | `packages-backend/<domain>-contracts`, types only (SKILL.md §3) |
|
||||
| both — genuine runtime code | see below |
|
||||
|
||||
Truly shared **runtime** code is rarer than it looks. Before creating one, check that it is
|
||||
free of Node built-ins (`fs`, `path`, `crypto`), free of DOM APIs, and free of anything
|
||||
environment-specific. If it is not, you have two packages, not one — and forcing it into a
|
||||
single package pulls one environment's assumptions into the other.
|
||||
|
||||
When such a package is justified, place it under the root of its **primary** consumer, keep it
|
||||
dependency-free, and document the constraint at the top of its README. A dependency added
|
||||
later is what breaks the other side.
|
||||
|
||||
## 2. Package tiers
|
||||
|
||||
| Tier | Purpose | Examples |
|
||||
|---|---|---|
|
||||
| Configuration presets | shared build/lint/type config | `eslint-config`, `typescript-config` |
|
||||
| Contracts | API types shared across the boundary | `<domain>-contracts` |
|
||||
| Domain libraries | business logic for one domain | `billing-core`, `user-core` |
|
||||
| Utilities | domain-independent helpers | `date-utils`, `result` |
|
||||
|
||||
Configuration presets are shared from day one — that is their job. Every other tier earns its
|
||||
existence only after a second consumer appears.
|
||||
|
||||
## 3. Package anatomy
|
||||
|
||||
```text
|
||||
packages-<side>/<package-name>/
|
||||
├── src/
|
||||
│ ├── index.ts # barrel — the entire public API
|
||||
│ └── <module>.ts
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── .eslintrc.cjs | eslint.config.js
|
||||
└── README.md # what it is, who consumes it, constraints
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@<scope>/<package-name>",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`src/index.ts` is the public API.** Anything not exported from the barrel is internal and may
|
||||
change freely; anything exported is a contract with every consumer. Deep imports into
|
||||
`dist/` or `src/` from a consumer defeat this — treat one as a bug in the consumer.
|
||||
|
||||
Script names must match the tasks in `turbo.json`, or Turborepo silently skips the workspace.
|
||||
|
||||
## 4. Changing a public API
|
||||
|
||||
Adding an export is safe. Changing or removing one is not.
|
||||
|
||||
1. Search every consumer across all four roots before editing:
|
||||
|
||||
```bash
|
||||
grep -Rn "from '@<scope>/<package>'" applications-backend applications-frontend \
|
||||
packages-backend packages-frontend
|
||||
```
|
||||
|
||||
2. Decide explicitly whether the change is backward-compatible.
|
||||
3. If it is not: update consumers in the same change, or add the new export alongside the old
|
||||
one, migrate, then remove.
|
||||
4. Build every affected consumer with its own `--filter`.
|
||||
|
||||
A shared package's dependencies propagate to every consumer, so weigh each one twice. This
|
||||
matters most in `packages-frontend/*`, where a dependency lands in a browser bundle that ships
|
||||
to users.
|
||||
|
||||
## 5. Configuration presets
|
||||
|
||||
`typescript-config` and `eslint-config` exist so rules live in one place. Every workspace
|
||||
extends them rather than restating `compilerOptions` or lint rules locally.
|
||||
|
||||
Their blast radius is the **whole repository** — a rule added to the shared ESLint preset can
|
||||
fail the build in workspaces you never opened. Change one, then run an unfiltered
|
||||
`pnpm lint && pnpm check-types` before committing.
|
||||
|
||||
Each side may need its own preset variant (a DOM `lib` for frontend, Node types for backend).
|
||||
Keep the shared base shared and put only the genuine differences in the variant.
|
||||
|
||||
## 6. Changesets and publishing
|
||||
|
||||
```bash
|
||||
pnpm changeset # describe the change, pick the bump
|
||||
pnpm publish-packages # build:ci + lint, then version and publish
|
||||
```
|
||||
|
||||
- One changeset per meaningful change, written for a consumer deciding whether to upgrade —
|
||||
not a restatement of the commit message.
|
||||
- A breaking change to an exported API is a **major** bump, even if every current consumer
|
||||
lives in this repository.
|
||||
- Never hand-edit versions or `CHANGELOG.md`; changesets owns both.
|
||||
- Internal-only packages are marked `"private": true` and are never published.
|
||||
|
||||
## 7. Mistakes to avoid
|
||||
|
||||
- Putting a package in the root matching its author instead of its consumers.
|
||||
- Creating a "shared" runtime package that imports Node built-ins or DOM APIs.
|
||||
- Giving a contract package a runtime dependency.
|
||||
- Exporting something from the barrel that was meant to be internal.
|
||||
- Deep-importing another package's `src/` or `dist/` from a consumer.
|
||||
- Changing an exported API without searching consumers first.
|
||||
- Adding a dependency to a frontend package without considering bundle size.
|
||||
- Restating `compilerOptions` or lint rules locally instead of changing the shared preset.
|
||||
- Editing versions or changelogs by hand.
|
||||
- Promoting logic into a shared package before a second consumer exists.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Workspace and Tooling
|
||||
|
||||
Root-level configuration: workspace globs, the Turborepo pipeline and its caching, root
|
||||
scripts, Prettier, dependency overrides, and lockfile discipline.
|
||||
|
||||
Read this when adding a workspace, changing `turbo.json` or root scripts, debugging a task
|
||||
that does not run or a cache that never hits, or touching `pnpm.overrides`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Workspace globs
|
||||
|
||||
```yaml
|
||||
# pnpm-workspace.yaml
|
||||
packages:
|
||||
- "applications-backend/*"
|
||||
- "applications-frontend/*"
|
||||
- "packages-backend/*"
|
||||
- "packages-frontend/*"
|
||||
```
|
||||
|
||||
Exactly one level of nesting under each root. A directory that no glob matches is invisible to
|
||||
pnpm and to Turborepo — it will not install dependencies, will not build, and will not fail
|
||||
loudly. The symptom is usually "my new package isn't found by its consumer".
|
||||
|
||||
After adding a workspace, run `pnpm install` so the workspace links are created.
|
||||
|
||||
## 2. Root `package.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"engines": { "node": ">=22", "pnpm": ">=10" },
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"check-types": "turbo run check-types",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,scss}\"",
|
||||
"publish-packages": "turbo run build:ci lint && changeset version && changeset publish",
|
||||
"prepush": "turbo build --filter=...[HEAD^]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `packageManager` pins the exact pnpm version; corepack reads it, so every contributor and CI
|
||||
runner resolves dependencies identically.
|
||||
- Root scripts **always** delegate to Turborepo, never to a single workspace — calling a
|
||||
workspace script directly bypasses the dependency graph and builds stale upstream code.
|
||||
- Root dependencies are tooling only: turbo, prettier, husky, changesets, typescript.
|
||||
|
||||
## 3. Turborepo pipeline
|
||||
|
||||
| Task | Depends on | Cached outputs |
|
||||
|---|---|---|
|
||||
| `build` | `^build`, `check-types`, `lint` | `dist/**`, `.next/**` |
|
||||
| `build:ci` | `^build:ci` | `dist/**`, `dist-internal/**` |
|
||||
| `lint` | `^lint` | — |
|
||||
| `check-types` | `^check-types` | — |
|
||||
| `dev` | — | — (persistent, never cached) |
|
||||
|
||||
`^task` means the task must complete in every upstream workspace dependency first. That is
|
||||
what makes `--filter` safe: filtering to one workspace still builds what it depends on.
|
||||
|
||||
Two consequences worth internalising:
|
||||
|
||||
- **`build` does not run until `check-types` and `lint` pass.** A type or lint error is a build
|
||||
failure for every downstream workspace, not a warning in one.
|
||||
- **Every output directory a workspace produces must be listed in `outputs`.** An unlisted
|
||||
directory is not restored from cache, so a "cache hit" silently yields a missing build
|
||||
artifact — which surfaces later as a container image built from nothing.
|
||||
|
||||
Task names in a workspace's `package.json` must match the task names in `turbo.json`. A
|
||||
mismatch is not an error; Turborepo just skips that workspace, and the task appears to pass.
|
||||
|
||||
Cache inputs include `$TURBO_DEFAULT$` and `.env*`.
|
||||
|
||||
## 4. Dependency rules
|
||||
|
||||
- Add a dependency to the **smallest** workspace that needs it.
|
||||
- Inter-workspace dependencies use `workspace:*`.
|
||||
- Run `pnpm install` after every `package.json` edit; commit the `pnpm-lock.yaml` change
|
||||
together with the edit that caused it. A lockfile committed separately is unreviewable.
|
||||
- Never hand-edit `pnpm-lock.yaml`.
|
||||
|
||||
### `pnpm.overrides` — security pins
|
||||
|
||||
```json
|
||||
{ "pnpm": { "overrides": { "undici": "^6.21.1", "ws": "^8.17.1" } } }
|
||||
```
|
||||
|
||||
Each entry exists because a **transitive** dependency shipped a vulnerability its direct
|
||||
parent had not yet picked up. Removing an entry silently reinstates the vulnerable version and
|
||||
nothing in the build complains — no error, no warning, a green pipeline.
|
||||
|
||||
Remove one only after confirming the direct dependency now resolves to a fixed version, and
|
||||
record that verification in the change.
|
||||
|
||||
## 5. Formatting
|
||||
|
||||
Root `.prettierrc` is the single authority for the whole repository. Do not add per-package
|
||||
Prettier config: two configs mean every file formatted from one side produces churn diffs when
|
||||
touched from the other.
|
||||
|
||||
```bash
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## 6. `Taskfile.yml`
|
||||
|
||||
The root `Taskfile.yml` is a **thin delegator** — it only `includes:` module Taskfiles under
|
||||
`.scripts/`. Never add inline commands to it. New automation becomes a `.scripts/<module>/`
|
||||
with its own Taskfile, which is then included.
|
||||
|
||||
## 7. Diagnosing common failures
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| New workspace not found by its consumer | no matching glob, or `pnpm install` not re-run |
|
||||
| Task appears to pass but does nothing | script name does not match a `turbo.json` task |
|
||||
| Cache hit but missing build artifact | output directory not listed in `outputs` |
|
||||
| Lockfile churn on every install | `packageManager` not pinned, or pnpm version drift |
|
||||
| Build fails only in CI | `--frozen-lockfile` exposing an uncommitted lockfile change |
|
||||
| Vulnerability reappears after an install | an overrides entry was removed |
|
||||
|
||||
```bash
|
||||
pnpm list --depth 0
|
||||
pnpm why <package>
|
||||
turbo run build --dry-run # what would run, and in what order
|
||||
turbo run build --filter=...[HEAD^]
|
||||
```
|
||||
|
||||
## 8. Mistakes to avoid
|
||||
|
||||
- Adding a workspace outside the four roots, or without updating the globs.
|
||||
- Calling a workspace script directly instead of going through Turborepo.
|
||||
- Adding a root dependency for a need local to one workspace.
|
||||
- Omitting an output directory from a task's `outputs`.
|
||||
- A workspace script name that does not match its `turbo.json` task.
|
||||
- Removing a `pnpm.overrides` entry without a security review.
|
||||
- Per-package Prettier configuration.
|
||||
- Inline commands in the root `Taskfile.yml`.
|
||||
- Committing a lockfile change apart from the `package.json` edit that caused it.
|
||||
Reference in New Issue
Block a user