| name | new-feature-hexa |
| description | Build a new vertical slice in the hexagonal architecture OUTSIDE-IN — start from the consumer's need (a use-case / acceptance test), let it pull the domain into existence, then implement the adapter. Use when adding any feature. Forces consumer-driven design, port reuse, and a pure core. |
New hexagonal feature (vertical slice, outside-in)
Add a feature as a thin slice through the layers, driven from the outside in:
the domain is a supplier, so its API must be pulled into existence by a real
consumer need — never pushed "just in case". Pair with tdd-cycle (the inner
red-green-refactor loop runs inside the outer acceptance loop = double loop).
Why outside-in: a domain function written before a consumer demands it is
speculative. The output shape is only known once its consumer (a writer, a UI,
an external format) exists. Let the consumer pin the contract.
0. Start from the consumer & the contract it needs
Name who will USE this and what observable result they need:
- A driving adapter (CLI command, web action) → expressed as a use-case.
- A driven side-effect (read a file, write output, call a service) → a port
whose shape is dictated by its real consumer.
If the output contract isn't yet known because its consumer doesn't exist, go
build/spike that consumer first. Don't invent the shape.
1. Reuse before you write (anti-duplication gate)
- Read
packages/core/src/application/README.md — the port/use-case registry.
- Grep so you reuse, not reinvent:
rg "export (interface|type|function|class)" packages/core/src
- If a side-effect already has a port (
NameSource, GreetingSink), reuse it.
Only add a port when none fits.
2. OUTER loop — failing acceptance test for the use-case
packages/core/src/application/<verb-noun>.spec.ts: write the use-case test
FIRST, with fake ports (in-memory stubs) standing in for the real adapters.
Assert the observable Result and what the fake received.
- Define the use-case signature it forces:
packages/core/src/application/<verb-noun>.ts — (input, deps) => Promise<Result>,
Result an explicit ok/error union.
- It fails because the domain it calls doesn't exist yet. Good — that failure is
your to-do list for the inner loop.
3. INNER loop — pull the domain into existence (TDD)
- Only now create domain units, and only the ones the outer test demands.
Where they are born: in the NURSERY (
packages/core/src/domain/<name>.ts,
flat) when the concept is new — or inside an existing feature module once
modules exist (core/src/<feature>/domain/) when it plainly belongs to one.
Never invent a feature folder for a first file: boundaries are discovered, not
decreed. (+ <name>.spec.ts, RED first per tdd-cycle: one behavior per
test, fake-it, triangulate.)
- Pure functions over your model. No
node:*, no globals (Biome noRestricted* +
Sheriff enforce it). New domain sub-folder? Add its tag to sheriff.config.ts.
- Ambient state is a port, never a global. Time, randomness, IDs, env config:
the moment a domain function wants one, stop and inject a port that yields the
value — the adapter reads the host, the core receives a plain value and does
pure arithmetic, a fixed fake pins it in tests.
fast-check for cross-input invariants.
- Stop when the outer acceptance test goes green. No extra domain API.
4. Adapter in web (the only impure code)
packages/web/src/... implements the port (Web Audio / localStorage / DOM /
file APIs) and a smart hook wires it into the UI: assemble input → inject real
ports → map Result. Adapters may import browser APIs — they live outside the
hexagon.
- Export the public surface from
packages/core/src/index.ts.
A new adapter package is this recipe at package scale: a package depending on
@app/core, adapters implementing the EXISTING ports, no new core code unless a
port is genuinely missing.
4bis. Extraction — when a module becomes apparent
The signal is the rule of three: a third file sharing a prefix/concept, a
use-case + port serving a single cluster. Naming the boundary stays YOUR call.
(The mechanism — dormant Sheriff placeholder rules for
core/src/<feature>/{domain,application} and the modules:hint script —
arrives with lot TS.5 of the template-sync plan.) Then:
- Name the module.
git mv the whole slice's center — domain files, its use-cases, its
ports (out of the nursery ports.ts), its fakes — into
core/src/<name>/{domain,application}.
- Let the gate enumerate the frontier. Each Sheriff violation is one
decision with three exits: join the module / promote to
shared/ (second
consumer only — never create there directly) / a declared one-line depRule
exception. Mikado stop rule: prerequisites deeper than ~2 levels → revert,
extract the prerequisites first.
- Depth check before closing (Ousterhout): exports vs files — a module
whose surface grows as fast as its contents was not ready.
5. Prove it & register
- Full gate green:
/quality-gate. Knip must not flag a new orphan export — if it
does, the export had no consumer (the very smell this skill prevents): wire it or
delete it.
- Knip blind spot:
@app/core's index.ts is the package entry, so knip
CANNOT flag a core public export that nothing consumes — it only catches
orphans inside packages. For the core surface, YOU are the check: before
exporting from index.ts, name the consumer (adapter, use-case, or the next
slice that pulls it); if you can't, don't export it yet.
- Append the new use-case/port to
packages/core/src/application/README.md.
6. Close the step
Run /session-report to update docs/STATUS.md and append a dated report.