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:
2026-08-12 23:49:02 +03:00
co-authored by Claude Opus 5
commit ff69bcca2f
14 changed files with 1250 additions and 0 deletions
+41
View File
@@ -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).
+287
View File
@@ -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"],
};
+52
View File
@@ -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"]
}
+176
View File
@@ -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.
+202
View File
@@ -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.
+128
View File
@@ -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.
+137
View File
@@ -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.
+143
View File
@@ -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.