Skip to content

The platform is live.

Take a look
The Lab
Architecture essay · 11 min read

Pick the shape before the code

Four repository shapes: a backend alone, a frontend alone, a full-stack monorepo, and one with a native app. Each as a tree with every folder's role and what it may import, named for roles rather than frameworks so it holds for Nest, Hono, Adonis, Next and Expo alike. Plus the prompt that lays a repository out this way.

TypeScriptNodeReactExpo
What it is
Four repository shapes, a backend alone, a frontend alone, a full-stack monorepo, and one with a native app, each as a tree with every folder's role and what it may import.
What you do with it
Read the tree that matches your consumers, or paste one line into a coding agent: it reads your repository, picks the shape, moves what is misplaced, and installs the test that keeps the import direction.
What it costs
Nothing, and no sign-up. The prompt never commits, so you review once, on the diff.
Who it is for
Any TypeScript service, web app or monorepo. The trees name roles rather than frameworks, so they hold for Nest, Hono, Adonis, Next and Expo alike.
What it is not
Not a starter kit to clone, and not a claim about good code. It is where things go and which way imports flow; the standard and the hardening recipe judge what is in them.
Open the treesTake the promptOr read on. The rest is why each folder is where it is.

Distilled from the same three production codebases as the standard, then checked against what the widely used starters actually do: the Turborepo examples, create-t3-turbo and next-forge for the monorepo shapes; bulletproof-react and the Next.js project-structure guidance for the feature slice; the Nest, Adonis and Hono conventions and the domain-driven-hexagon reference for a service on its own; Expo Router's own rules and Expo's monorepo guide for the native app. Where those sources disagree, the page says so and picks.

01

The tree is the first prompt an agent reads

The situation

Point a coding agent at a repository and the first thing it reads is the tree. Before any instruction file, before any rule, it has already learned where things go from where things are, and it will put the next thing where the nearest similar thing sits. That is not a flaw in the model. It is the only sensible strategy when the structure is the only map.

So the shape of the repository is the first prompt, and most teams never write it. They start from whatever the framework generator produced, add folders as the product grows, and a year later the tree says nothing true about the system: a components folder with two hundred files, a utils file with eighty-seven helpers, a route file long enough to need its own table of contents.

This piece is the four shapes I start from, as trees, with what may import what. It is the layout half of the standard: the standard says what a codebase has to hold, this says where each thing lives, so that an agent, or a person on their first day, can find it without asking.

02

The 4 shapes, and who they are for

counted from the data, not estimated

4repository shapes, one per set of consumers
19roles a folder can take, each with the failure behind it
71allowed import edges; every other edge is a violation
8places the starters disagree, each settled with a pick
14things wired on day one, across the shapes
17patterns rejected on the way, each with why
01

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.

A web app deployed elsewhereA native appA partner over HTTPAn agent or an MCP server
02

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.

A browser, through the app's own route handlers
03

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.

A browser, through the web appThe web app, through the APIA worker, a partner, an agent
04

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.

A browser, through the web appA phone, through the native appBoth, through the API

The consumers decide. Count who reads from the code and what is deployed; the shape follows. A monorepo arrives with the second consumer, not before.

03

The four trees

click a folder for its role; flip to the tree without a shape

Every folder carries a role, and the role decides what it may import. Pick a folder to read what goes in, what stays out and the failure that made the rule. The second view is the same repository with no shape: the tree an agent builds by nearest file, and what that costs.

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.
The tree
  1. api/
  2. src/
  3. rate-limit.ts
  4. <provider>/
CLAUDE.md

the map, the roles, the import direction

The instruction layerinstructions

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.

19 roles, one vocabulary for all 4 trees. A folder's role decides what it may import, and a test derives that rule from the tree and holds it.

04

A folder is named for what it may import

roles, not frameworks

The trees name roles: 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. The name changes; the rule does not. Transport gates, validates, calls one service and wraps the result, and it decides nothing.

Naming by role does one more thing. It makes the import direction a property of the folder, so a violation is visible from the path alone: a service importing a route file is wrong before you read a line of it, and a test can derive that rule from the tree and hold it.

Each role below carries the failure that made it a rule. That is deliberate. A folder convention without its defect gets applied ceremonially, and a ceremony is the first thing a team drops under pressure.

The 19 roles a folder can take

instructionsThe instruction layerThe 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.

transportTransportRequests 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.

middlewareMiddlewareCross-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.

validationValidationThe 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.

serviceServiceThe 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.

domainDomainPure 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.

dataData accessThe 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.

adaptersAdaptersEvery 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.

jobsJobsWork 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.

contractsContractsThe 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.

configConfigSecrets 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.

observabilityObservabilityStructured 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.

routerRouterPages, 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.

featuresFeature slicesnever a siblingOne 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, and 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.

uiDesign systemThe 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.

libShared libCross-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.

appsAppsThe 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.

packagesPackagesWhat 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.

testsTestsThe 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.

What may import what

The same edges the prompt states and the layering test derives, drawn once. Everything flows toward contracts and config; nothing flows back up.

may importtransportmiddlewarevalidationservicedomaindataadaptersjobscontractsconfigobservabilityrouterfeaturesuilibappspackages
transport
middleware
validation
service
domain
data
adapters
jobs
contracts
config
observability
router
features
ui
lib
apps
packages
tests
71 allowed edges. A filled cell means the row may import the column; an empty one is a violation; the red outline on the diagonal is a slice that never imports a sibling. Tests import everything and nothing imports the instruction layer, so those two are left off the axes.
05

Where the starters disagree, and the 8 picks

the survey's visible product

The trees were checked against the starters people actually clone. On most things they agree, and the trees follow them. On these they do not, so each one is settled here with a side and a reason, and the prompt follows the pick rather than relitigating it in your repository.

01Tests beside the code, or in one folder?The pick 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.

The sides Nest, the Hono starters and the domain-driven-hexagon reference colocate *.spec.ts; Adonis, Fastify and the Node TypeScript Architecture guide keep tests/ mirroring src.

Why A file the glob never matches is read as coverage and never runs. Which convention matters less than there being exactly one.

02Feature slices, route-colocated, or flat?The pick Feature slices, with the router thin.

The sides bulletproof-react slices by feature; the Next.js docs and the colocation templates put components beside their route in private folders; create-t3-app and the SaaS starter stay flat: app, components, lib.

Why 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.

03Controllers, or inline handlers?The pick Transport is a role, not a class. A route file that gates, validates, calls one service and wraps the result satisfies both camps.

The sides Nest and Adonis are controller-first. Hono's own guidance says not to build Rails-style controllers, because the path types cannot be inferred through them.

Why What matters is that the handler decides nothing. Whether that is a class method or a function is the framework's business.

04Shared tool config in packages/, or in tooling/?The pick packages/config.

The sides The Turborepo examples put eslint-config and typescript-config under packages/; create-t3-turbo gives them their own tooling/ folder.

Why 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.

05May a package depend on another package?The pick Allowed, downward only, by the role rules: validation imports contracts, ui imports lib, nothing imports apps.

The sides Turborepo allows it; next-forge's structure guide says a package should not depend on other packages.

Why Forbidding it copies the contracts into every package, which is the drift the split was meant to end.

06Share the UI across web and native?The pick Share the seam: contracts, domain, the client and the tokens. Each app keeps its own primitives and screens.

The sides Solito and the Expo monorepo example share components across both; create-t3-turbo keeps a web-only ui package and lets the app own its screens.

Why The two UIs diverge for good reasons, and a shared package becomes the lowest common denominator of both.

07Hexagonal at small scale?The pick The part that pays: a pure domain, adapters at the edge, a thin transport. Ports and use-case folders wait for a second caller.

The sides The domain-driven-hexagon reference says its full shape is wrong for small and medium applications; the Nest and Adonis defaults have no ports at all; Hono goes further and inlines the handlers.

Why Ceremony per table turns a five-table service into forty files, and a team routes around it within a month.

08Where does the API live in a monorepo?The pick apps/api, a deployable.

The sides create-t3-turbo serves packages/api from the Next app and lists it as a dev dependency in the Expo app; the kitchen-sink example and next-forge deploy apps/api on its own.

Why 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.

06

DRY, KISS, SOLID, and the useful half of hexagonal

held by a folder, not by a sentence

The principles everyone cites, each stated as the thing in the tree that holds it. A principle with no folder behind it is a request; these are the folders.

  1. DRY: one source per fact

    Contracts, validation and domain math exist once and are imported by every surface that needs them.

    Held by packages/contracts and packages/validation in a monorepo; features/<domain>/validation.ts and types.ts imported by the route, the hook and the tool surface in a single app; a query-key factory per domain.

    The failure A bound copied into a second file drifted, and two schemas accepted different inputs for the same field.

  2. KISS: nothing before the second caller

    A package, an abstraction or a monorepo appears when the second consumer does, not before.

    Held by The shapes are ordered: a single app until a second deployable exists; packages extracted in the order a consumer needs them; the seam (an interface, a nullable column) comes early and cheap, the body waits for the task that needs it.

    The failure A monorepo split for one consumer was a rewrite nobody asked for, and a helper written ahead of its second caller was deleted when the caller never arrived.

  3. SOLID: the three that matter here

    Single responsibility per module, split by responsibility and never by line count. Open for extension through seams: a new provider is a new adapter, not an edited caller. Dependency inversion at the trust boundary: services take the injected client and nothing reaches for its own connection.

    Held by Transport, validation, service and data as four folders importing downward; adapters/ as the seam; the client constructed once in data and passed in everywhere.

    The failure A route that did database work and a service that returned a response object each crossed the line the whole security model depends on.

  4. Hexagonal, the part that pays

    The domain is pure and at the centre, adapters at the edge normalise every third party, and transport is thin. The rest of the ceremony waits for a second caller.

    Held by domain/ imports only contracts; adapters/ is the only folder that knows a provider's types; a route file is gate, validate, one call, envelope.

    The failure Full ports-and-use-cases ceremony per table made a five-table service into forty files, and the team routed around it within a month.

07

Wired on day one

batteries included, and which ones were left out

A shape is not a starter until the checks exist: typecheck, lint at zero warnings, a runner whose include glob is the one convention, an env manifest that fails the build, CI that gates the deploy, and the instruction layer. Each shape lists its set, and what the survey turned up that it does not do.

Wired on day one, and rejected on the way, per shapefor engineers
A backend aloneWired 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
  • 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.
A frontend aloneWired 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
  • 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.
A full-stack monorepoWired 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
  • 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.
With a native appWired 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
  • 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.
08

The prompt

composed from the trees above, so it cannot drift from them

Take this with you

Shape this repository

Written for Claude Code, and composed from the trees on this page, so the prompt and the page cannot disagree. Point it at any TypeScript repository and let it run: it maps the consumers and the stack, picks the shape, maps every path to a role, moves files in isolated worktrees with the checks green after each merge, wires what is missing, and installs the layering test that keeps the import direction, proven by breaking it. It never commits, so you review once, on the diff.

  1. 0A
    How to run thisAs an orchestrator, end to end, without asking: reading fans out to subagents, independent moves happen in isolated worktrees, and every claim is refuted by an agent that did not make it.
  2. 0B
    Absolute constraintsNever commits. A move is a move, with no behaviour change in the same unit. Reads no secret, writes to no database, and reverts anything that leaves the checks red.
  3. 01
    Know the repository firstConsumers and deployables from the manifests and the hosting config, the stack and what each dependency decides, the tree from real files, and the instruction layer as claims to check.
  4. 02
    Pick the shapeThe consumers decide: one server with no screen, one web app, a second consumer, a native client. A monorepo when the second consumer exists, not before.
  5. 03
    Map every path to a roleOne subagent per top-level folder returns the target role and path for everything in it, and whether that is a move, a split, a merge or a delete. A second agent refutes the table row by row.
  6. S
    The shapesThe four trees, composed from this page: when to pick each, every folder with its role and note, what is wired on day one, and what was rejected on the way.
  7. R
    The rolesEvery role a folder can take: what it is called in each framework, what goes in, what stays out, what it may import, and the failure that made the rule.
  8. D
    Where the starters disagreeThe calls the well-used starters make differently, each settled with a pick and its reason, so the agent follows the pick instead of relitigating it in the plan.
  9. 04
    MoveSerial items first in the main tree, then one builder per slice in its own worktree, merged one at a time with the checks green after each. Splits before moves. Nothing fixed on the way.
  10. 05
    Wire what is missingTypecheck and lint at zero warnings, the runner's glob as the one convention, the env manifest, CI that gates the deploy, and the instruction layer with the ledger's first entry.
  11. 06
    The guardrail, and break itThe layering test, derived both ways from the tree and the roles, then broken on purpose by a different agent and restored byte-identical.
  12. 07
    The final reportWritten into the repository: the shape and why, every row of the map and its outcome, what was wired, what the test caught, what was left alone, what stayed blocked, and every claim it could not verify.

The trees travel with the prompt, so it is long. Paste this line instead and your agent fetches the whole thing itself; the copy and download buttons work too.

Read https://alicantorun.com/lab/repository-shapes.md in full and follow it exactly, against this repository. Do not summarise it, do not plan around it: execute it, starting with the map.

Run it in Claude Code on Opus 5 with maximum effort, in a fresh session with a long budget; it spawns its own subagents and worktrees. It does not stop to ask, so give it the whole run, then read docs/SHAPE.md and the diff before anything is committed.

The whole prompt

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.

09

What I actually think, stated plainly

the position

Four shapes are enough. Every repository I have built or audited was one of them, or one of them pretending to be another: a full-stack app split into a monorepo for one consumer, a backend wearing a frontend's folder names. The decision is made by counting deployables and clients, and it is the one structural decision that is cheap on day one and expensive on day four hundred.

The trees are opinionated and the roles are not. Move a folder, rename a role to what your framework calls it, keep the direction. The direction is the standard's area A: imports flow one way, the table is derived both ways, and the test that holds it is the first guardrail the prompt installs. Everything else on this page is a default you are allowed to argue with.

The honest part: a shape does not produce good code. It produces a place for good code to go, and a way for an agent to find it. Whether the service in the service folder is any good is a different question, and the standard and the hardening recipe are the two pieces that ask it.

10

The companion pieces

what goes in the folders

This page is where things go. What has to be true of them is the standard, whose area A is the import direction drawn above, and the instruction layer every tree starts with has its own essay, the five files that run this codebase.