Shape this repository
You are working in a repository you may not have written. Your job is to lay it out in one of
4 shapes, so that a coding agent or an engineer on their first day can find where a
thing lives from the tree alone, and to leave behind the one test that keeps the import
direction true after you are gone.
Read it as roles, not as a stack. The trees below name folders for what they may import:
transport, validation, service, domain, data access, adapters. A Nest reader calls the first one
a controller, a Hono reader a route file, an Adonis reader the routes folder, a Next reader a
route handler. Keep this repository's framework names where they are established; keep the rules
exactly. Where this stack has no home for a role, say which folder takes it and why, in the plan.
How to run this
You run inside Claude Code or a harness like it, on your own, end to end: map, pick, plan, move,
verify, report. You never ask the owner which shape or what to move; the consumers decide the
shape and the roles decide the moves. Nothing is committed, so the diff is the owner's review and
undo.
Work as an orchestrator, not as a single reader:
- Do not read the whole repository yourself. Reading is delegated to subagents that return
short findings in one shape; your context is for the map, the plan and the report.
- Independent moves happen in isolated worktrees. One builder per slice, each with a brief,
each verified before its branch is merged into the main tree, one at a time.
- Every claim is checked by an agent that did not make it. The map, the mapping table and the
final layering test are each refuted by a second agent before you rely on them.
- Nothing here is a ceiling. Where the repository needs a folder these shapes do not name, add
it with a role and a rule, and record it.
Absolute constraints
- Never commit, push, tag or open a pull request. Never rewrite history. The owner reviews the
diff.
- A move is a move: no behaviour change in the same unit as a relocation. Renames go through
git mv, so history follows the file.
- Read no secret value. Check that a variable exists, never what it holds.
- Run nothing that writes to a database or spends money. The repository's own typecheck, lint and
test commands are enough, against a local database if one is configured.
- After every merged move, typecheck, lint and the tests are green, or the move is reverted and
recorded as blocked with the reason.
Phase 1: know the repository first
Fan out readers and write the map at the top of docs/SHAPE.md:
- Consumers and deployables. Who reads from this code: a browser, a native app, a worker, an
agent, a partner over HTTP. What is deployed: one process, several, a static site. Read it from
the manifests, the workflow files and the hosting config, not from the README.
- The stack. Framework, HTTP layer, database client, validation, test runner, package manager,
workspace tooling if any. Note what each dependency decides: does the client throw or resolve,
is there a mutation path that bypasses the API, does the runner's include glob match the files
that look like tests.
- The tree as it is. Every top-level folder and what actually lives in it, from real files,
never from its name.
- The instruction layer.
CLAUDE.md, AGENTS.md, rules, ADRs, cursor and copilot files.
Claims to check, not truth.
Phase 2: pick the shape
The consumers decide, and you record the decision with its reason in docs/SHAPE.md:
- One deployable that serves HTTP and has no screen of its own: a backend alone.
- One web app, with its own route handlers as the only data path: a frontend alone.
- A web app and a separately deployed API, or any second consumer of the same domain code: a
full-stack monorepo.
- Any native client, now or planned within the year: with a native app. A native app alone is
the frontend shape with its own router and no route handlers, because its API is always a
separate deployable.
A monorepo is picked when a second consumer exists, not before. Splitting for one consumer is a
rewrite nobody asked for.
Phase 3: map every path to a role
One subagent per top-level folder. For every folder, and every file that is not clearly inside
one, it returns the target role, the target path in the chosen shape, and whether this is a move,
a split, a merge or a delete. A file that does two roles' work is a split, listed under both.
Write the table into docs/SHAPE.md. A second agent refutes the table: every row cites the file
it read, and a row that cannot is removed.
The shapes
1. A backend alone
One deployable that serves HTTP and has no screen of its own. Slices per domain, four layers importing downward, jobs beside them.
Pick it when: There is one server process and its clients are elsewhere: a separate web app, a native app, a partner, an agent.
Not when: The server exists only to serve one web app it is deployed with; that is the frontend shape, with route handlers.
Consumers: A web app deployed elsewhere; A native app; A partner over HTTP; An agent or an MCP server.
api/
CLAUDE.md # instructions: the map, the roles, the import direction
docs/DECISIONS.md # instructions: append-only, newest first
src/
app.ts # transport: compose middleware once, mount every route; no listen, so tests import it
server.ts # transport: boot: listen, and nothing else
routes/ # transport
health.ts # degrades per dependency, never a bare 200
<domain>.ts # gate, validate, one service call, envelope
middleware/ # middleware
auth.ts # identity, then ownership; 404 to a stranger
rate-limit.ts
errors.ts # the one place an outcome becomes a status
modules/ # one slice per domain; a slice never imports a sibling
<domain>/ # features
validation.ts # validation: schemas at module level, types inferred
service.ts # service: takes the client, scoped by the resolved tenant
queries.ts # data: this domain's queries; the client lives in db/
types.ts # contracts
domain/ # domain: pure rules and money math, no IO
adapters/ # adapters
<provider>/
client.ts # one wrapper: duration, uniform errors, logs
normalise.ts # raw provider types never leave this folder
captured.ts # types from real responses, not the spec
jobs/ # jobs
worker.ts # claim, run, settle, with a lease
reapers.ts # one per status that can get stuck
db/ # data
client.ts # constructed once, injected everywhere
schema/ # declarative; the source of truth
migrations/ # additive; never edited once applied
types.ts # generated, never edited
config/ # config
env.ts # requireEnv, fails closed
env-manifest.ts # a missing variable fails the build
observability/ # observability
logger.ts # info, error, alarm
after.ts # post-response work that survives the freeze
tests/ # tests: one convention; the runner's include glob is the truth
setup-env.ts # refuses any non-local database URL
helpers.ts # provisions real tenants through the real path
layering.test.ts # the import direction, derived both ways
.github/workflows/ci.yml # lint, typecheck, migrate, test; gates the deploy
package.json # one package manager, pinned; a cooldown on fresh versions
Wired on day one:
- Strict TypeScript: no any, no ts-ignore
- Lint at zero warnings, with an import-direction rule
- A test runner whose include glob is the one convention
- An env manifest that fails the build on a missing variable
- CI: lint, typecheck, migrate, test, one job that gates the deploy
- A layering test derived both ways
- The instruction layer: the root file, scoped rules, the ledger
Rejected on the way, and why:
- Type folders: controllers/, services/, models/: A change touches three folders and the domain boundary is invisible, so an agent reaches for the nearest file in each.
- Full hexagonal ceremony per entity: ports/, use-cases/ and adapters/ for every table: The seam without the second caller. Keep the domain pure and the adapters at the edge; that is the part of hexagonal that pays.
- Tests in a second folder the runner does not include: Read as coverage, never run.
- One module per table, importing each other: A slice is a domain, not a row. Once modules import sideways the boundary is gone and a domain change touches every module.
2. A frontend alone
One web app with its own route handlers as the only data path. Feature slices, a thin router, one design system, one typed client.
Pick it when: There is one web app and it is the only consumer of its own server code.
Not when: A second client appears, or the API needs to be deployed on its own; that is the monorepo.
Consumers: A browser, through the app's own route handlers.
web/
CLAUDE.md # instructions: the map, the roles, the import direction
.claude/rules/ # instructions: scoped by path, loaded when touched
docs/DECISIONS.md # instructions
app/ # router
layout.tsx # fonts, metadata, providers
(marketing)/ # a route group: no URL segment, its own layout
(app)/ # the gated product
layout.tsx # gate once for everything under it
[slug]/page.tsx # one service call for initial data, then a feature component
api/
_middleware/ # middleware: the gates, validateRequest
v1/<domain>/route.ts # transport: gate, validate, service, envelope
features/ # one slice per domain; a slice never imports a sibling
<domain>/ # features
components/ # this domain's UI, client side
hooks/ # React Query hooks; every key from keys.ts
services/ # service: server-only; takes the client
validation.ts # validation
types.ts # contracts
components/ # ui
ui/ # primitives; tokens in globals.css
header.tsx # cross-cutting chrome, and nothing else lives here
lib/ # lib
fetch-api.ts # the one client that reads the envelope
env.ts # config: requireEnv plus the manifest
logger.ts # observability
db/ # data: the client, generated types
<provider>.ts # adapters: normalised at this boundary
db/ # data: schema/, migrations/; declarative and additive
tests/ # tests: real services, a local database that refuses any other
.github/workflows/ci.yml # lint, typecheck, test; gates the deploy
Wired on day one:
- Strict TypeScript: no any, no ts-ignore
- Lint at zero warnings, with an import-direction rule
- A test runner whose include glob is the one convention
- An env manifest that fails the build on a missing variable
- CI: lint, typecheck, test, one job that gates the deploy
- A layering test derived both ways
- The instruction layer: the root file, scoped rules, the ledger
- One typed fetch client, and a query-key factory per domain
- Design tokens and primitives in one place
Rejected on the way, and why:
- pages/ plus components/ plus utils.ts: The nearest-file structure: every feature lands in the biggest file.
- Server actions as the mutation path: Invisible to a native app, an agent, an MCP server or a partner; one framework owns every write.
- Hooks and query keys global, typed inline: An inline key silently breaks an invalidation.
- A barrel index.ts per folder: Defeats tree-shaking and hides which file an import reaches. The feature-slice reference and the monorepo tooling docs both say avoid them now.
3. A full-stack monorepo
A web app and an API deployed separately, sharing what a second consumer needs through packages, in the order it needs them.
Pick it when: A second consumer of the same domain code exists: a separately deployed API, a worker fleet, a second app.
Not when: There is one app and one deployable. A split for one consumer is a rewrite nobody asked for.
Consumers: A browser, through the web app; The web app, through the API; A worker, a partner, an agent.
repo/
CLAUDE.md # instructions: the map: apps, packages, the import direction
pnpm-workspace.yaml # one package manager; workspace protocol pins
turbo.json # typecheck, lint and test per package; build only for apps
apps/ # apps: each one a whole shape; never imports another app
web/ # the frontend shape, whole
api/ # the backend shape, whole
packages/ # packages: extracted in the order a second consumer needs them
contracts/ # contracts: ids, enums, wire shapes, generated db types
validation/ # validation: schemas both apps import
domain/ # domain: pure rules; tested with no database
db/ # data: schema, migrations, the client; one owner
adapters/ # adapters: providers, normalised once
ui/ # ui: web primitives and the tokens
client/ # lib: fetchApi, query keys, hooks; the seam a native app will import
config/ # config: tsconfig, eslint and prettier presets
tests/ # tests: cross-package invariants; each package keeps its own
.github/workflows/ci.yml # turbo run lint typecheck test; affected only on a PR
Wired on day one:
- Strict TypeScript: no any, no ts-ignore
- Lint at zero warnings, with an import-direction rule
- A test runner whose include glob is the one convention
- An env manifest that fails the build on a missing variable
- CI: lint, typecheck, test, one job that gates the deploy
- A layering test derived both ways
- The instruction layer: the root file, scoped rules, the ledger
- Workspace protocol pins, and a typecheck per package
- A verify script that runs the whole pipeline locally
Rejected on the way, and why:
- A monorepo before a second consumer exists: A rewrite nobody asked for, whose packages have one importer each.
- packages/utils and packages/types as catch-alls: No owner, so everything lands there and the schema gets copied as interfaces.
- Apps importing across apps: The web bundle pulls the server's dependencies, and the boundary that made the split worth doing is gone.
- Reaching into a package by relative path: ../../packages/ui/src/button bypasses the package boundary and its exports field. It works today and breaks on the first restructure.
- The API as a package the web app serves: A server wearing a package's name. It cannot be deployed alone, and the native app ends up importing server code for types that belong in contracts.
4. With a native app
The monorepo with an Expo app beside the web app: the two share the seam (contracts, domain, the client, the tokens) and keep their own screens.
Pick it when: Any native client exists or is planned within the year. A native app alone is the frontend shape with Expo Router, and its API is always a separate deployable.
Not when: The native app is a hypothetical. Add the shared packages the day it is real.
Consumers: A browser, through the web app; A phone, through the native app; Both, through the API.
repo/
CLAUDE.md # instructions: the map: three apps, the shared seam, the import direction
pnpm-workspace.yaml # one package manager; workspace protocol pins
turbo.json
apps/ # apps
web/ # the frontend shape, whole
api/ # the backend shape, whole
mobile/ # the frontend shape with Expo Router; no route handlers of its own
app/ # router: Expo Router: screens and layouts, nothing else
features/<domain>/ # features: components and hooks; the services stay in the API
components/ui/ # ui: native primitives; tokens from packages/tokens
lib/ # lib: the query client; imports packages/client
app.config.ts # config
eslint.config.js # zero warnings; a downgraded rule is a lint that always passes
packages/ # packages
contracts/ # contracts: the one task shape all three apps agree on
validation/ # validation
domain/ # domain: the rules web and native must agree on
db/ # data
adapters/ # adapters
tokens/ # ui: colour, spacing, type; consumed by web and native
ui/ # ui: web only; native keeps its own primitives
client/ # lib: fetchApi, keys, hooks; both apps import it
config/ # config
tests/ # tests
.github/workflows/ci.yml # plus a check that every package resolves before the native build
Wired on day one:
- Strict TypeScript: no any, no ts-ignore
- Lint at zero warnings, with an import-direction rule
- A test runner whose include glob is the one convention
- An env manifest that fails the build on a missing variable
- CI: lint, typecheck, test, one job that gates the deploy
- A layering test derived both ways
- The instruction layer: the root file, scoped rules, the ledger
- Workspace protocol pins, and a typecheck per package
- A verify script that runs the whole pipeline locally
- Native lint at zero warnings
- A resolve check before building on a package
Rejected on the way, and why:
- Sharing screens between web and native: The two UIs diverge for good reasons. Share the seam (client, contracts, domain, tokens), not the screens.
- Copying hooks into the native app: They are six months old within a quarter; the client package is the shareable seam.
- A lint config that downgrades every rule to a warning: A bare lint exits zero no matter what is wrong.
- A native dependency installed in two places: Expo lists duplicate react-native and react versions as unsupported, and the errors are the kind no file explains. Native dependencies live in the app; packages stay JavaScript-only.
The roles
Every folder above carries one of these. The import direction is the rule; the name is what
your framework calls it.
The instruction layer (instructions)
The files an agent reads before it touches anything: the standing instructions, the scoped rules, the decision ledger.
Known as: CLAUDE.md or AGENTS.md at the root, .claude/rules/ or .cursor/rules/, docs/DECISIONS.md; the same files a new engineer reads first.
Goes in: The map: what the repository is, the shape and roles it uses, and the import direction, in a few hundred lines; Rules scoped by path, loaded only when the work touches that area; The append-only ledger of decisions, newest first, each with the alternative it rejected.
Stays out: Anything the code already says: file enumerations, copied constants, a paragraph restating a check; Narrative history; that goes to an archive the rules point at.
May import: nothing.
The failure: A rule nothing checked stopped being true and nobody noticed, and the agent followed the prose over the code.
Transport (transport)
Requests in, responses out, and nothing decided in between.
Known as: Controllers in Nest, route files in Hono, Express and Fastify, the routes folder in Adonis, app/api route handlers in Next.
Goes in: The route declaration and its method; The gate call, first, before any other work; The validate call, against a schema that lives in validation; One service call, and the envelope around its result; An explicit return type on every handler.
Stays out: A business decision, a query, a third-party call; A tenant, owner or account id read from the request body; Error strings from the database or a provider.
May import: middleware, validation, service, contracts, config, observability.
The failure: A handler that validated, decided, queried and formatted in one thousand-line file, so nothing in it could be tested or reused.
Middleware (middleware)
Cross-cutting concerns written once and composed once: identity, rate limits, request ids, the error map.
Known as: Guards, interceptors and pipes in Nest, middleware in Hono, Express and Adonis, app/api/_middleware in Next, the proxy or middleware file for the whole app.
Goes in: The gates that resolve identity and then ownership, answering 404 to a stranger; Rate limiting, request ids, timing; The one place a service outcome becomes a status code.
Stays out: Domain logic dressed as a guard; A second copy of a gate, adapted for one route.
May import: service, contracts, config, observability.
The failure: The same guard copied into every handler but one, and the one was the route that mattered.
Validation (validation)
The shape of what comes in, declared once at module level, and the input type inferred from it.
Known as: DTOs with class-validator in Nest, VineJS validators in Adonis, the zod schema beside the route in Hono, features/<domain>/validation.ts in Next, packages/validators in create-t3-turbo.
Goes in: Schemas for every request body, query and param; Field bounds, enums for status and role, email as email; The inferred input types the service accepts.
Stays out: Hand-rolled if/else checks in a handler; A bound copied into a second file.
May import: contracts.
The failure: A bound hardcoded in a tool surface drifted from the API schema, and the two accepted different inputs for the same field.
Service (service)
The work: takes the injected client and a params object, scopes every query by the resolved tenant, returns an outcome.
Known as: Providers in Nest, services in Adonis and Hono, features/<domain>/services in Next, use cases in clean architecture.
Goes in: Every read and write of tenant data, filtered by the resolved id; Outcomes, not responses: a boolean miss, a typed error, a result; The orchestration of domain rules, data access and adapters.
Stays out: An HTTP response object; Its own database connection; Presentation: formatting, envelopes, status codes.
May import: domain, data, adapters, contracts, config, observability.
The failure: A service that returned a response object could not be called by the agent tool or the MCP server, so each grew its own copy of the logic.
Domain (domain)
Pure rules and math with no input or output: state machines, money arithmetic, eligibility.
Known as: Entities and value objects in DDD, the core in hexagonal architecture, lib/domain or packages/domain, the part that runs in a unit test with no database.
Goes in: Functions from values to values; Invariants stated as types: an exhaustive switch, a branded id; The rules a native app and a web app must agree on.
Stays out: A client, a fetch, a clock read, a random number; Anything that knows about HTTP or a table.
May import: contracts.
The failure: Settlement math that read the balance and then wrote it lost updates under concurrency; once it was a pure delta the database could apply it atomically.
Data access (data)
The database client, the schema, the migrations and the queries, owned in one place.
Known as: Repositories in Nest and in hexagonal designs, database/ with its migrations and factories in Adonis, src/db in the Hono starters, the Prisma, Drizzle or Supabase client module, packages/db in create-t3-turbo and next-forge.
Goes in: The client, constructed once and injected; Declarative schema files and additive migrations; Queries scoped by the tenant column; paging on every read that can exceed the row cap; Generated types, regenerated after every schema change.
Stays out: A query inside a handler or a component; A hand-written status union the database also declares.
May import: contracts, config, observability.
The failure: A hand-written status union compiled and passed every test while every insert on the new surface violated the CHECK in production.
Adapters (adapters)
Every third party behind one boundary, normalised on the way in and wrapped once on the way out.
Known as: Ports and adapters, the infrastructure ring, lib/stripe and lib/github, packages/adapters, providers in Adonis.
Goes in: One client per provider and one orchestration wrapper: duration, uniform errors, structured logs; Types captured from real responses, never from the spec; Normalisation, so raw provider types never leave this folder.
Stays out: A provider type in a service signature; A retry loop copied per call site.
May import: contracts, config, observability.
The failure: One provider documented 97 fields and returned 170, and a transformer that coalesced an absent key to zero destroyed the distinction settlement needed.
Jobs (jobs)
Work that runs without a request: queue consumers, a reaper for every status that can get stuck, scheduled sweeps.
Known as: Processors and schedules in Nest, Ace commands and the scheduler in Adonis, a cron route plus a worker in Next, a queue consumer anywhere.
Goes in: The worker loop: claim, run, settle, with a lease; A reaper per non-terminal status: publishing, indexing, running; One healthy-signature log line per run.
Stays out: Business logic; a job calls a service; A status flipped to terminal only inside a request's catch block.
May import: service, contracts, config, observability.
The failure: A scheduled job that idled in silence was indistinguishable from one that had died, and a status flipped only in a catch block stayed stuck after a hard kill.
Contracts (contracts)
The types every layer agrees on: ids, enums, wire shapes, generated database types.
Known as: types.ts per feature, DTO interfaces, packages/contracts or packages/types in a monorepo, the generated database types file.
Goes in: Wire shapes for every route, shared by the handler, the hook and the native app; Ids and enums, once; Generated types, never edited by hand.
Stays out: Logic of any kind; A type only one file uses.
May import: nothing.
The failure: The native app and the web app each carried their own copy of the task shape, and a field added on the server reached one of them.
Config (config)
Secrets read once and failing closed; every non-secret tunable a typed constant in the module that owns it.
Known as: ConfigModule in Nest, the env file with a schema in Adonis, lib/env.ts plus an env manifest in Next, packages/config for the shared tool presets.
Goes in: requireEnv, which throws on a missing value, and the manifest that fails the build; Typed constants for model ids, caps and thresholds, next to what they govern; Shared tool presets: tsconfig, eslint, prettier.
Stays out: process.env.X ?? 'default', a fallback set once and forgotten; A constant an operator would want to see with no place that lists it.
May import: nothing.
The failure: A silent env fallback pointed a deploy at the wrong project, and nothing in the build said so.
Observability (observability)
Structured logs at three levels, where the third means a control fired, and post-response work that survives the runtime freeze.
Known as: A Logger provider in Nest, the logger service in Adonis, lib/logger.ts and lib/after.ts in Next, pino anywhere.
Goes in: One logger: info, error, alarm; The one persist() that flushes after the response; Typed analytics events.
Stays out: Raw console output on the server; An alarm for a retryable failure.
May import: config.
The failure: A metering call issued after the response was dropped when the runtime froze, so the work was done and never billed.
Router (router)
Pages, layouts and route groups, and nothing else. The router routes.
Known as: app/ in Next and in Expo Router, where only page and route files are public and anything else may sit beside them in a private folder; routes/ in Remix and TanStack Router; the pages directory before any of them.
Goes in: Layouts, pages, and route groups that add no URL segment; The server-side gate on a page, then one service call for its initial data; Metadata.
Stays out: A component of any size; a page imports one from its feature; State, fetching in an effect, a query key.
May import: features, middleware, service, ui, lib, contracts, config.
The failure: A page file that fetched, held state and rendered five hundred lines was the nearest file every time, so every new feature landed in it.
Feature slices (features)
One folder per domain with everything the domain needs. On the server: its validation, service, queries and types. On the client: its components, hooks, schemas and types.
Known as: features/ in bulletproof-react and in this site's portal, modules/ in Nest, the slice in vertical-slice architecture.
Goes in: components/ and hooks/ for this domain, with keys.ts holding every query key; validation.ts and types.ts, imported by both halves; services/ on the server, taking the injected client.
Stays out: An import from a sibling slice; what two slices need moves down to ui, lib or contracts; A component two domains use.
May import: ui, lib, validation, contracts, config, observability. Never a sibling of the same role.
The failure: A shared module that reached up into a feature became the precedent the next change cited, and the direction was gone.
Design system (ui)
The primitives every screen is built from, and the tokens they are coloured with.
Known as: components/ui with shadcn, packages/ui in a monorepo, the theme and tokens file, the native primitives folder in an Expo app.
Goes in: Buttons, inputs, dialogs, the sidebar: primitives with no domain knowledge; Tokens for colour, spacing and type, once; Cross-cutting chrome: header, footer, shell.
Stays out: A domain component; A raw colour utility where a token exists.
May import: lib, config.
The failure: A raw input where a design system existed produced a second dialect of every form, and a grey that merely looked reasonable failed contrast on the one role nobody checks.
Shared lib (lib)
Cross-cutting helpers that know nothing about any feature: the typed fetch client, the query client, dates, ids.
Known as: lib/ in Next, packages/client or packages/lib in a monorepo, utils/ while it is still small, the shared seam a native app imports.
Goes in: fetchApi, the one client that reads the envelope; The query client and its defaults; Small pure helpers with a second caller.
Stays out: Anything that imports a feature; Eighty-seven unrelated helpers in one file.
May import: contracts, config, observability.
The failure: A helper in lib that imported a feature inverted the layering everything else assumed, and the next helper cited it as precedent.
Apps (apps)
The deployables, one folder each, and each one is a whole shape on its own.
Known as: apps/ in Turborepo, create-t3-turbo and next-forge: the web, api and expo or mobile folders, each with its own package.json and never nested.
Goes in: apps/web, the frontend shape; apps/api, the backend shape; apps/mobile, the frontend shape with Expo Router as its router.
Stays out: An import from another app; what two apps need is a package; A package's code copied in because the import felt heavy.
May import: contracts, validation, domain, ui, lib, adapters, data, config, observability.
The failure: The web app imported a type from the API's source tree, and the web bundle pulled the server's dependencies with it.
Packages (packages)
What a second consumer needs, extracted in the order it needs it: contracts, validation, domain, tokens and ui, adapters, the client seam, config.
Known as: packages/ in Turborepo, create-t3-turbo and next-forge, under one scope such as @repo; libs/ in an Nx workspace; any folder with its own package.json under pnpm workspaces.
Goes in: One package per role, with the same import rules as the role; A package.json each, pinned with the workspace protocol, exposing files through the exports field rather than one barrel; A typecheck per package, so a broken one fails on its own.
Stays out: An import from apps/; A package extracted before a second consumer exists.
May import: nothing.
The failure: A monorepo split made before a second consumer existed was a rewrite nobody asked for, and its packages were imported by exactly one app each.
Tests (tests)
The verification floor: real services against a local database that refuses any other, placed by one convention that the runner's include glob is the truth of.
Known as: *.test.ts next to the code, or tests/ at the root, or tests; the include glob in the runner config decides which one this repository uses, and files outside it are not tests.
Goes in: Integration tests through the real service layer; A derived test for every invariant that cost money or trust: both sides computed, nothing hand-listed; Fixtures and helpers that provision real tenants.
Stays out: A test that asserts a branch, or a mock of the thing under test; A file the runner's glob never matches, read as coverage.
May import: transport, middleware, validation, service, domain, data, adapters, jobs, contracts, config, observability, router, features, ui, lib.
The failure: Test files the runner's include glob never ran, read as coverage for months.
Where the starters disagree
These are settled here so you do not relitigate them in the plan. Follow the pick; record it
in the ledger if the repository did otherwise.
- Tests beside the code, or in one folder? One convention per repository, and the runner's include glob is the truth of it. Integration tests through real services sit at the root here, because that is where the floor is. A file the glob never matches is read as coverage and never runs. Which convention matters less than there being exactly one.
- Feature slices, route-colocated, or flat? Feature slices, with the router thin. Route colocation ties a domain to a URL it may outgrow, and a flat tree is the nearest-file trap: every feature lands in the biggest file.
- Controllers, or inline handlers? Transport is a role, not a class. A route file that gates, validates, calls one service and wraps the result satisfies both camps. What matters is that the handler decides nothing. Whether that is a class method or a function is the framework's business.
- Shared tool config in packages/, or in tooling/? packages/config. One rule for what a package is: a folder with its own package.json. A second top-level folder is one more thing to explain to an agent.
- May a package depend on another package? Allowed, downward only, by the role rules: validation imports contracts, ui imports lib, nothing imports apps. Forbidding it copies the contracts into every package, which is the drift the split was meant to end.
- Share the UI across web and native? Share the seam: contracts, domain, the client and the tokens. Each app keeps its own primitives and screens. The two UIs diverge for good reasons, and a shared package becomes the lowest common denominator of both.
- Hexagonal at small scale? The part that pays: a pure domain, adapters at the edge, a thin transport. Ports and use-case folders wait for a second caller. Ceremony per table turns a five-table service into forty files, and a team routes around it within a month.
- Where does the API live in a monorepo? apps/api, a deployable. A package that is a server is a deployable wearing a package's name, and the native app then imports server code for types it should get from contracts.
Phase 4: move
Serial first, in the main tree, one at a time: anything the whole tree depends on (the config
role, the contracts role, the shared lib). Then the independent slices go to builders in isolated
worktrees, one per domain, each with the mapping rows for its slice and its own verification.
Merge one at a time; typecheck, lint and tests green after each, or revert and record.
Rules for a move:
git mv, never delete-and-create.
- Update every import in the same unit, and nothing else.
- A file that held two roles is split into two files first, then each is moved. The split is its
own unit with its own green run.
- Do not fix what you find on the way. Write it in the report under "seen, not touched".
Phase 5: wire what is missing
Each shape lists what is wired on day one. For every item this repository lacks, add it, smallest
version first, each in its own unit:
- Typecheck and lint as commands, lint at zero warnings.
- The test runner's include glob matching where the tests actually are, and one convention.
- The env manifest, so a missing required variable fails the build.
- A CI workflow that runs lint, typecheck and tests, gating the deploy.
- The instruction layer: a root file that states the shape, the roles and the import direction in
a few hundred lines, scoped rules where an area needs them, and the ledger with this change as
its first entry.
Phase 6: the guardrail, and break it
Write the layering test: derive every import edge between role folders from the tree, derive the
allowed edges from the roles above, and compare both ways, so a new violator fails and so does a
fixed one that is still listed as tolerated. Then a different agent introduces one wrong import on
purpose, watches the test fail, restores the file byte-identical and hands back the proof. A test
that has never failed is decoration.
Final report
Append to docs/SHAPE.md: the shape and why; the mapping table with each row's outcome; every
move, split and delete; what was wired and what already existed; what the layering test caught
when it was broken; every path left where it was and why; everything seen and not touched; every
step left blocked; and every claim you could not verify. Never commit for the owner.