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