| name | lattice-refactor |
| description | Refactor an existing TypeScript project to the lattice feature-folder architecture with enforced boundaries, using a TDD red-green-refactor discipline so behavior is preserved at every step. Use when a user wants to adopt the lattice structure, "features" folder structure, ESLint boundaries plugin, and full linting/format/test guardrails in an existing codebase. |
lattice-refactor
You are refactoring an existing TypeScript project into the lattice architecture: a feature-folder layout enforced at lint time by eslint-plugin-boundaries, with one-way imports (global → feature → app).
The hard rule of this skill: never make a structural move without a passing test that proves behavior is preserved. Every move is a red-green-refactor cycle. Vertical slices only — one piece of behavior at a time, end-to-end.
Target architecture
src/
app/ # pages / route handlers / entrypoints ONLY (consumers, not producers)
features/
<feature>/
api/ # feature-specific API logic
components/ # feature-specific UI
db/ # feature-specific data access
lib/ # feature-specific utilities
components/ # global shared UI
lib/ # global shared utilities
styles/ # global styles
e2e/ # cross-feature end-to-end tests
Import rules enforced by lint:
| From | May import from |
|---|
shared (lib/components) | shared only |
feature/<X> | shared, feature/<X> (same feature) |
app | shared, feature/*, app CSS |
A feature may never import another feature. Global modules may never import features. Pages live only in app/ and consume — they do not contain business logic.
Workflow
You will move through these phases. Confirm with the user before moving from one phase to the next.
Phase 0 — Survey (read-only)
Before changing anything:
- Run the existing test/lint/build commands and record what's currently green vs red.
- Inventory the codebase. Produce a short table the user signs off on:
- Likely features — clusters of files that talk to each other but should be isolatable (e.g.
cart, checkout, profile).
- Truly global — utilities, UI primitives, design system pieces used in ≥2 candidate features.
- Pages/routes — files that should end up in
src/app/ as thin consumers.
- Unknowns — files you can't classify yet. List them; don't guess.
- Identify the package manager, framework, and test runner in use. Do not change them. lattice is structure-agnostic — it can be applied to Next.js, Vite, Remix, Nest, plain Node, etc.
Output of Phase 0: a written migration plan the user approves before any file moves.
Phase 1 — Install guardrails (no moves yet)
The only required addition is ESLint with the boundaries plugin. Everything else (formatter, git hooks, test runner, parallel runner) is optional, and the project may already have an equivalent. Inspect first, then ask. Do not install anything beyond the required core without explicit user confirmation.
1a. Inspect what's already there (read-only)
Before suggesting any install, look for existing tooling. Read files; do not modify.
For each tool category below, record what (if anything) the project already uses:
| Category | Look for |
|---|
| Formatter | .prettierrc*, prettier key in package.json, biome.json, dprint.json, .editorconfig-only setups |
| Git hooks | .husky/, lefthook.yml / lefthook.yaml, .pre-commit-config.yaml, simple-git-hooks in package.json, husky dev dep, native .git/hooks/ scripts |
| Test runner | vitest, jest, mocha, ava, tap, node --test, bun test, playwright (e2e separate), existing test script in package.json |
| Parallel runner | concurrently, npm-run-all / npm-run-all2, turbo, nx, existing check/ci/all script |
| Pre-commit staged-files runner | pretty-quick, lint-staged, mrm-installed equivalents |
| Existing ESLint setup | eslint.config.* (flat), .eslintrc* (legacy), eslint dev dep, existing lint script |
| TypeScript | tsconfig.json, typescript dev dep |
Then report back to the user with a compact summary like:
"Here's what I found:
- Formatter: Prettier already configured (
.prettierrc)
- Git hooks: Husky in
.husky/, with lint-staged on pre-commit
- Test runner: Jest (
test script runs jest)
- Parallel runner: none —
check script chains with &&
- ESLint: legacy
.eslintrc.json, no boundaries plugin"
1b. Install the required core
These are the only deps this skill installs unconditionally:
pnpm add -D eslint @eslint/js typescript-eslint \
eslint-plugin-boundaries eslint-import-resolver-typescript
If eslint is already installed, keep the existing version (don't downgrade). If the project uses a legacy .eslintrc* config, propose migrating to flat config (eslint.config.js) — flag this and wait for user approval before touching it, since it changes how all of their existing lint rules load.
Drop in a starter flat ESLint config (boundaries enforcement comes in Phase 4):
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: [
'dist/**',
'build/**',
'node_modules/**',
'.cache/**',
'coverage/**',
],
},
];
If the user already has ESLint rules they want to keep, merge them into this config rather than replacing — and walk through the merge with them.
Add the lint scripts to package.json (merge with any existing scripts; do not overwrite):
{
"scripts": {
"lint": "eslint . --cache --cache-location .cache/eslint",
"lint:fix": "eslint . --fix --cache --cache-location .cache/eslint"
}
}
Verify: pnpm lint runs. Commit.
1c. Ask before adding any optional tooling
Now, for each missing or different tool from your Phase 1a inspection, ask the user explicitly before installing. Do not default to yes. Frame each question as opt-in, and offer the option to integrate with whatever they already use.
Suggested questions (skip categories where they already have a working equivalent):
- Formatter — "You don't have Prettier configured. Want me to add Prettier with
singleQuote: true and a fmt / fmt:fix script? (You can also skip this entirely if you'd rather rely on editor formatting.)"
- If yes: install
prettier, write .prettierrc ({ "singleQuote": true }), add fmt/fmt:fix scripts.
- If they already have Biome / dprint / something else: leave it alone. Note it for Phase 1d so you wire
check:all correctly.
- Git hooks — "Want a pre-commit hook that runs lint on staged files? Options: install Lefthook (the lattice default), use your existing Husky setup, or skip git hooks entirely."
- If Lefthook: install
lefthook, write lefthook.yml, run pnpm exec lefthook install.
- If they have Husky: add a
.husky/pre-commit entry that runs pnpm lint (and lint-staged / pretty-quick only if they opted into the formatter step). Don't install Lefthook.
- If skip: move on.
- Pre-commit staged-files runner — only ask if the user opted into both a formatter and git hooks, and don't already have
lint-staged or pretty-quick. "Want pretty-quick to format only staged files on commit?"
- Test runner — "I noticed [Jest / no test runner]. Vitest is the lattice default, but the structure works with any runner. Want me to add Vitest, keep your existing runner, or skip the test step?"
- If keep existing: leave it alone, just make sure the
test script exists.
- If add Vitest: install
vitest, write a minimal vitest.config.ts, add test / test:watch scripts. Only do this if the project does not already have a working test runner.
- If skip: don't add
test to check:all.
- Parallel
check:all — "Want a pnpm check:all script that runs fmt + lint + typecheck + test in parallel? Options: install concurrently, use your existing npm-run-all / Turbo, or chain with && (sequential, no extra dep)."
- Install
concurrently only if user picks that option AND no parallel runner exists.
- Otherwise wire
check:all using whatever they already have, or && if they want zero extra deps.
- Developer-experience extras — "Optional:
eslint-plugin-only-warn (turns ESLint errors into warnings locally so the IDE is less noisy; CI can still fail on warnings) and eslint-config-prettier (disables ESLint rules that conflict with Prettier — only useful if you opted into Prettier). Add either, both, or neither?"
Rule of thumb: if the user has a working equivalent, integrate with it. If they don't and they say no, skip it. Never install a tool just because lattice happens to use it.
1d. Stitch the final check:all script
Based on what the user accepted, assemble a check:all script that runs only the steps they have. Examples:
- All four (fmt + lint + typecheck + test) with
concurrently:
"check:all": "concurrently -n fmt,lint,typecheck,test -c blue,magenta,cyan,green \"pnpm fmt\" \"pnpm lint\" \"pnpm typecheck\" \"pnpm test\""
- Lint + typecheck only, sequential, zero extra deps:
"check:all": "pnpm lint && pnpm typecheck"
- Lint only (minimum viable):
"check:all": "pnpm lint"
Verify: pnpm check:all runs (may report existing warnings — that's fine). Commit.
Phase 2 — Plant the safety net (TDD red phase)
You will not move a single file until that file's behavior is covered by a test that you can run. Tests are the contract.
For each piece of behavior the user agrees is critical (start with the most-changed or most-business-critical paths — do not try to cover everything):
RED: Write ONE integration-style test that exercises the behavior
through its current public entry point. Test fails or doesn't exist yet.
GREEN: If the behavior already works, the test should pass against existing code.
If it fails, fix the test (not the code) until it passes against current
behavior. The test is now a tripwire.
Rules for these tests:
- Test behavior, not structure. "User can add an item to the cart and see total update" — not "the
addToCart function calls cartReducer with action type ADD."
- Use real code paths. Hit the real module surface, not internal helpers. No mocking of internal collaborators. Mock only true external boundaries (network, time, filesystem).
- One test, one behavior. No mega-tests.
- Tests live next to the code they cover for now (
foo.ts → foo.test.ts). They'll travel with the code when you move it.
Stop when the user agrees the safety net covers what matters. Commit.
Phase 3 — Move code, one vertical slice at a time
This is the heart of the skill. Pick ONE feature from your Phase 0 inventory and migrate it end-to-end before touching any other feature.
For each slice:
RED: Run `pnpm test` — your safety-net tests for this feature should be GREEN
before you touch anything. If they're not green now, stop. Fix the test
or the code first. Never refactor while red.
MOVE: Move the feature's files into `src/features/<feature>/{api,components,db,lib}/`.
- Update imports
- Co-locate the tests
- Do NOT change behavior
- Do NOT add new functionality
- Do NOT "tidy up" along the way
GREEN: Run `pnpm test` again. All safety-net tests still pass.
Run `pnpm typecheck`. Clean.
Run `pnpm build` (or the equivalent). Clean.
REFACTOR: Only now, with tests green, look for cleanup *inside the moved feature*:
- Dead code that was only there for the old location
- Internal helpers that should be one layer deeper
- Duplication between `api/` and `db/` that should consolidate
Run tests after each refactor step.
Order of operations within a slice:
- Move
lib/ (no dependencies) first.
- Then
db/.
- Then
api/ (depends on db).
- Then
components/ (may depend on api/lib).
- Then update
app/ callers to import from the new feature location.
- Delete the old files.
- Commit the slice. One feature, one commit (or one PR).
If a "feature" file is used by another feature, it isn't a feature — it's shared. Move it to src/lib/ or src/components/ instead. Surfacing this is one of the most valuable outcomes of the migration; don't paper over it by duplicating.
Phase 4 — Enable boundaries enforcement
Once all candidate features have been migrated, swap the starter ESLint config for the full boundaries-enforcing config. Only include the plugins the user actually opted into in Phase 1c — strip the eslintConfigPrettier import if the user declined Prettier, and strip onlyWarn if they declined that. The required boundaries portion is the same either way.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginBoundaries from 'eslint-plugin-boundaries';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: { parserOptions: { projectService: true } },
},
{
files: ['**/*.config.{js,mjs,cjs,ts}', '**/eslint.config.js'],
...tseslint.configs.disableTypeChecked,
},
{
plugins: { boundaries: pluginBoundaries },
settings: {
'import/resolver': { typescript: { alwaysTryTypes: true } },
'boundaries/include': ['src/**/*'],
'boundaries/elements': [
{
mode: 'full',
type: 'shared',
pattern: [
'src/components/**/*',
'src/lib/**/*',
'src/styles/**/*',
'src/e2e/**/*',
],
},
{
mode: 'full',
type: 'feature',
capture: ['featureName'],
pattern: ['src/features/*/**/*'],
},
{
mode: 'full',
type: 'app',
capture: ['_', 'fileName'],
pattern: ['src/app/**/*'],
},
{
mode: 'full',
type: 'neverImport',
pattern: ['src/*', 'src/tasks/**/*'],
},
],
},
rules: {
'boundaries/no-unknown': ['error'],
'boundaries/no-unknown-files': ['error'],
'boundaries/element-types': [
'error',
{
default: 'disallow',
rules: [
{ from: ['shared'], allow: ['shared'] },
{
from: ['feature'],
allow: [
'shared',
['feature', { featureName: '${from.featureName}' }],
],
},
{ from: ['app', 'neverImport'], allow: ['shared', 'feature'] },
{ from: ['app'], allow: [['app', { fileName: '*.css' }]] },
],
},
],
},
},
{
ignores: [
'dist/**',
'build/**',
'node_modules/**',
'.cache/**',
'coverage/**',
],
},
];
Run pnpm lint. Treat every violation as a RED signal — work through them with the same loop:
RED: boundaries violation: file X imports from disallowed location Y
GREEN: either move the import target to the correct layer, or move the
importer, or extract the truly-shared bit to src/lib/
TEST: safety-net tests still green
Adjust the boundaries/elements shared pattern only to match real folders in the project (e.g. add src/server/**/* if the project has one). Never weaken the element-types rules to make code pass — that defeats the entire point of the migration.
Phase 5 — Lock it in
- Document the structure: add
docs/project-structure.md summarizing the rules and showing an import diagram.
- Verify
pnpm check:all is green.
- Verify lefthook pre-commit fires (
git commit on a bad import should fail).
- Update the README's project-structure section.
Anti-patterns to refuse
- Horizontal migration. Don't move all
lib/ files for all features, then all db/, then all api/. That leaves the codebase broken for hours. Vertical slices only.
- Writing tests in bulk before any moves. You're describing imagined behavior, not real behavior. Write a test, verify it's a real tripwire, move the code, then write the next one.
- "Just this once" boundary exceptions. If a feature needs another feature's code, the shared piece is global — promote it to
src/lib/ or src/components/. Don't add an eslint-disable.
- Touching feature internals during a move. Refactor is the third step, not the first. If you spot improvements during MOVE, write them down for the REFACTOR step — don't do them inline.
- Adding new behavior during migration. No new features. No "while I'm here" fixes. Migration commits change structure, not behavior.
Communication with the user
- After Phase 0, present the inventory and wait for approval before installing anything.
- After Phase 1, confirm
pnpm check:all runs before planting safety-net tests.
- After Phase 2, confirm the safety net is sufficient before moving any file.
- Per slice in Phase 3, show the file moves you're about to make and wait for go-ahead if anything is ambiguous. After the slice, report: tests still green, types still green, build still green.
- After Phase 4, walk through any boundaries violations together — these are usually the most valuable conversations of the whole migration because they surface accidental coupling.
Default to small, reviewable commits. Default to asking before doing something irreversible (deleting old files, force-pushing). The user's existing tests and their patience are the two scarcest resources — protect both.