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>
5.5 KiB
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
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
{
"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.
- Search every consumer across all four roots before editing:
grep -Rn "from '@<scope>/<package>'" applications-backend applications-frontend \
packages-backend packages-frontend
- Decide explicitly whether the change is backward-compatible.
- If it is not: update consumers in the same change, or add the new export alongside the old one, migrate, then remove.
- 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
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": trueand 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/ordist/from a consumer. - Changing an exported API without searching consumers first.
- Adding a dependency to a frontend package without considering bundle size.
- Restating
compilerOptionsor 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.