A reusable starter for a pnpm monorepo with a pure hexagonal core,
strict TDD, and a blocking quality gate — plus Claude Code skills that
encode the method. Domain-agnostic: it ships one tiny example slice (greet) and
nothing else, so you can replace it with your own domain immediately.
This starter optimizes long-term drift prevention, not onboarding speed —
the gate runs six blocking checks in pre-commit, coverage is 100 % per file,
duplication has a zero threshold, mutation runs locally before every PR, and
each step closes with a session report. That ceremony pays for itself when the
product is durable, framework-independent domain logic: a CLI, a backend,
or a UI that carries a substantial domain — the UI is just another adapter
consuming @app/core
(ADR-0007: the starter ships
none on purpose, React/Angular/Vue plug in like the CLI does). The method is
designed to be agent-operated (the skills encode it), and assumes a team
willing to work test-first.
It is the wrong tool for a prototype, for thin CRUD that mostly delegates to a remote API, or for a codebase with little logic independent of its framework — there, the ceremony costs more than it protects.
- Hexagonal layering, enforced three ways:
- the package graph (
@app/corepure ←@app/cliadapter), - Sheriff (
sheriff.config.ts) on the module graph — layers AND emergent feature modules: dormant placeholder rules tag anycore/src/<feature>/…folder the moment it exists, features are isolated by default (sameTag+shared), and a feature can never import the nursery (see the anatomy below and ADR-0006), - Biome
noRestricted*(override onpackages/core) for the no-I/O / no-ambient-state purity invariant Sheriff can't see, - a fitness function (
packages/core/src/purity.spec.ts) for what neither can express:Mathis fine,Math.random()is not. It tests its own detector, so it can't quietly stop working.
- the package graph (
- Errors are values, and they are tags. The domain returns
Result<T, E>; failures travel as{ kind: 'empty-name' }, not as English sentences. The adapter (cli/src/report.ts) decides the wording, the language and the exit code, exhaustively — add a tag and the build breaks until it is handled. Atry/catchwraps one port call, never a use-case body, so a genuine bug still crashes instead of arriving as a polite{ ok: false }. - Parse, don't validate.
HourOfDayis branded with aunique symbol, so it can only be produced byhourOfDay— which makessalutationFortotal, with no defensive check and no error case to invent. - Determinism is part of purity. No
Date.now(),Math.random(),crypto.randomUUID(), timers orprocess.envinside the hexagon — inject a port that yields the value.Clockis the worked example:SystemClockreads the host, the core gets anInstantand does pure arithmetic on it, and a test pins time withFixedClockinstead of hoping CI runs in the morning. - Blocking quality gate (
pnpm gate): TypeScript strict, Biome lint+format, Sheriff, vitest with 100 % coverage thresholds on every file, knip (dead code), jscpd (duplication, threshold 0). Greenfield = no debt tolerated, a finding fails the build. - Mutation testing (Stryker, scoped to the pure core) — run locally before the PR, and in CI post-merge.
- A self-truthful architecture map (
docs/ARCHITECTURE.md): a module-level Mermaid diagram generated from the same graph Sheriff enforces (pnpm arch:map) — an emerged feature appears as a subgraph the moment it is extracted, and the gate fails if the committed map drifts from the tree. - TDD strict with fast-check property tests; one example vertical slice, tested
at three altitudes that catch different things:
- port contracts (
@app/core/testing) — written once per port, replayed against every implementation, so adapters stay substitutable; - acceptance test (
cli/src/run.spec.ts) — the real composition root in process, only the process boundary doubled; - binary test (
cli/src/main.spec.ts) — the shipped bin under plainnode.
- port contracts (
- No build step: the bin runs the
.tssources directly through Node's type stripping, so the sources stay in the strip-only subset (no parameter properties, enums, namespaces, decorators) — an invariant held twice:erasableSyntaxOnlymakestscreject the syntax anywhere in the tree (even in a file no import reaches yet), and a test runs the real binary under plainnode. - Guardrails: husky
pre-commit(gate) +commit-msg(commitlint), ablock-commit-on-mainhook (code needs a branch+PR; docs may go straight to main). - CI (GitHub Actions), two tiers: gate + commitlint + dependency audit on
PRs; mutation and the Windows gate post-merge on
mainor on demand (workflow_dispatch) — portability is checked where the promise is made, not on every push. Dependabot for the bumps. - Claude Code skills:
/tdd-cycle,/new-feature-hexa,/quality-gate,/session-report(the close-step discipline: report ships in the PR, mutation run locally pre-PR).
# scaffold a new project from this template
npx degit IIIvan37/hexagonal-tdd-starter my-project
cd my-project
corepack enable # Node 24 only — see the note below for Node ≥ 25
pnpm install
pnpm gate # everything green
pnpm greet Ada # → Good {morning,afternoon,evening}, Ada! — the real clock decidesRequires Node ≥ 24 (see .nvmrc) — not a whim: the bin runs the .ts sources
through Node's native type stripping, no build step. pnpm: Node 24 still
bundles Corepack, so corepack enable is enough; Node ≥ 25 does not ship it
anymore — run npm install -g corepack && corepack enable (or install pnpm
directly) before pnpm install.
The husky hooks and block-commit-on-main only guard your machine — and
GitHub settings don't travel with a template: every project cloned from this
one must enable protection itself. A collaborator, or you on another checkout,
can push straight to main unless the remote enforces it too:
gh api -X PUT repos/{owner}/{repo}/branches/main/protection --input - <<'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["Quality gate", "Commit messages"]
},
"required_pull_request_reviews": { "required_approving_review_count": 0 },
"enforce_admins": true,
"restrictions": null
}
EOF(JSON via --input on purpose: gh api -F cannot express the null that this
endpoint requires for restrictions.)
Without this, the local hooks are a convention, not a guarantee.
Pick your enforce_admins — it decides the fate of the doc-only exception
(the local convention that lets a *.md/docs/** commit land straight on
main):
true(above): everything goes through a PR, docs included — required checks apply to admins too, so a direct doc push is refused. Strictest, and the doc-only exception effectively dies at the remote.false: collaborators are held to PRs, but admins keep the direct doc-only path. The pragmatic choice for a solo maintainer — it matches how the local hooks behave.
The greet slice is a worked example, not a feature — it exists to be read
once and replaced. Every file that belongs to it carries a first-line marker:
grep -rln "EXAMPLE" packages # the full list, always current| Marker | Meaning |
|---|---|
EXAMPLE … DELETE |
dies with the example slice |
EXAMPLE CONTENT, SKELETON ROLE |
keep the file, replace its contents: the composition root (run.ts), the error mapping (report.ts), the index exports, and the three test altitudes |
KEEP |
generic skeleton that only looks example-adjacent (shared/result.ts) |
Everything unmarked (toolchain, hooks, purity.spec.ts, docs/) is skeleton.
greet lives extracted (core/src/greet/{domain,application,testing}) on
purpose: it shows the end state of the module lifecycle, while the flat
domain/ and application/ folders are the nursery where your own files
are born. Modules are discovered, not decreed — the signal, the extraction
procedure and the enforcement are
ADR-0006; pnpm modules:hint
points at candidates when the nursery grows.
pnpm eject:example # driven by the markers above
pnpm install && pnpm check:fix && pnpm gate # → green, empty skeletonThe script (scripts/eject-example.ts) deletes the
DELETE-marked files, rewrites the SKELETON ROLE ones as minimal stubs — the
three test altitudes stay alive, so the strip-only invariant remains locked even
before your first feature — empties the registry, and removes the two
dependencies knip would rightly flag (@app/core in cli, fast-check;
re-add them the moment a feature needs them). shared/result.ts and its spec
are kept: coverage is 100 % per file, a kept file keeps its spec.
Doing it by hand instead? The blind run costs four failed gate passes — follow the script's source as the checklist.
Then: /new-feature-hexa, outside-in.
- Rename the packages (
@app/core,@app/cli) and the rootname. The specifier lives in two places that must stay in sync: the packageexports(packages/core/package.json) — which tsc, vitest and the bin all resolve through — and thepathsintsconfig.json, which exist only for Sheriff (without themcheck:archgoes green-but-blind on package imports; the comment there has the details). - Tear out the example (see Anatomy above), then build your first real
slice outside-in (
/new-feature-hexa,/tdd-cycle). - Adjust the Biome core-purity denylist, the
purity.spec.tsrules, and the Sheriff tags/depRules as your layers grow. A new adapter package needs BOTH its Sheriff tag and a Biome override banning@app/core/testingoutside specs (copy thepackages/clione). - Keep
docs/STATUS.md+docs/sessions/current via/session-report.
packages/core/src/domain NURSERY: new domain files are born here, flat
packages/core/src/application NURSERY: use-cases + ports (+ the registry README)
packages/core/src/shared the kernel — grows by promotion only (Result lives here)
packages/core/src/greet an EXTRACTED feature module: {domain,application,testing}
packages/core/src/testing the @app/core/testing barrel (re-exports feature test kits)
packages/core/src/index.ts the only public surface adapters import (fitness-checked)
packages/cli/src/adapters port implementations (I/O lives here)
packages/cli/src/run.ts composition root (testable in process)
packages/cli/src/main.ts entrypoint — the process boundary, nothing else
.claude/skills the method, as Claude Code skills
docs/STATUS.md the present state — bounded, rewritten, never a log
docs/sessions rolling window of the 5 last reports (+ archive/)
docs/adr why the constraints exist (read before removing one)
See CONTRIBUTING.md for the loop and the non-negotiables, and docs/adr/ for the reasoning behind them. Licensed under MIT.