File & folder structure
Before any code is written, someone decides where it goes. This page answers what a folder is cut by, which folders may import which, and what a filename is allowed to say.
Group folders by feature, not by technical role
Section titled “Group folders by feature, not by technical role”Impact: HIGH decides where every future file lands
- A folder is an area of the domain (
labeling/,billing/,identity/). Everything that area needs lives inside it. Adding a feature is one new folder; deleting one is onerm -r. - Layer-first folders (
models/,policies/,services/,routes/) make every feature touch five folders and wire them together with../../, which no tool can see.services/turns into the place for “not a model, not a policy, not a route”. - The
shared/folder is guarded. A module moves there only once a second feature needs it. - This is the modular monolith: one deployable, hard internal boundaries. Between packages the
boundary is the package import; between features inside
apps/serverit is a lint rule.
❌ Incorrect — layer-first, one feature smeared across four folders:
apps/server/src/ models/ labeling-rule.ts invoice.ts policies/ labeling-rule-policy.ts invoice-policy.ts services/ labeling-rules-service.ts rate-limit-helpers.ts routes/ labeling-rules.ts invoices.ts✅ Correct — one folder per feature, with what that feature needs:
apps/server/src/ labeling/ labeling-rules.ts labeling-rule-errors.ts labeling-rules-repo.ts policy.ts http.ts billing/ identity/Source: notes/01-code-organization/file-structure.md · Decision, axis 1
Reach into another feature only through its public files
Section titled “Reach into another feature only through its public files”Impact: HIGH the feature boundary inside one deployable
- Features are folders inside
apps/server, not packages, so the resolver does not guard them. oxlint’sno-restricted-importsdoes. - Any feature may import another feature’s service, errors, schemas and types. Another feature’s
*-repo.tsandpolicy.tsare imported only from inside that feature: call its service instead, so its policy runs. See coupling & cohesion.
❌ Incorrect — billing reads labeling’s storage directly, skipping its policy:
import * as LabelingRulesRepo from "../labeling/labeling-rules-repo.ts" // lint errorimport * as Policy from "../labeling/policy.ts" // lint error✅ Correct — go through the owning feature’s service:
import * as LabelingRules from "../labeling/labeling-rules.ts"Source: notes/01-code-organization/file-structure.md · Decision, axis 1 (amended)
Classify each feature as foundation or domain, and keep folders flat
Section titled “Classify each feature as foundation or domain, and keep folders flat”Impact: HIGH fixes the direction of every import between features
- Foundation is
infra/,identity/,orgs/,authz/, plus the@app/domainandcontractspackages. Every other feature folder is a domain. - A domain may import foundation and other domains. Within one pair of domains, only one direction is a direct import; the other goes through an event or a port the imported side owns. Foundation never imports a domain.
- The layout does not change. Folders stay flat, and the foundation list lives in
.oxlintrc.jsonand the coupling note, not in the path. - A module two domains share moves down to its nearest common owner, which is foundation
(
packages/domain/src/shared/or a foundation feature), never a third domain.
❌ Incorrect — the class carried by a parent folder, or a share parked in another domain:
apps/server/src/ foundation/infra/ foundation/authz/ ← every infra/ and authz/ path moves labeling/rule-quota.ts ← used by billing too: a domain-to-domain share✅ Correct — flat folders; the class is a list:
apps/server/src/ infra/ identity/ orgs/ authz/ foundation (listed in .oxlintrc.json) labeling/ billing/ domainpackages/domain/src/shared/ where a share between two domains goesSource: notes/01-code-organization/file-structure.md · Decision, axis 1 (amended)
Name files in plain kebab-case
Section titled “Name files in plain kebab-case”Impact: MEDIUM fewest rules, no suffix that goes stale
- A file is the kebab-case form of what it defines:
run-coordinator.ts,context-epoch.ts. The folder says what kind of thing it is. No casing that encodes “this one is a service”. - No role suffix (
message-policy.tsbesidemessage.ts) and no transport suffix (.http.ts,.sse.ts). Where two transports serve one feature, the folder tells them apart. - The one sanctioned exception is the tool suffix
.test.ts, with an optional aspect infix (http.contract.test.ts). It names what a tool picks up, not a role. See test placement. - Accepted cost: you cannot tell from a filename whether a module needs a Layer.
❌ Incorrect — casing, role and transport suffixes carrying meaning:
labeling/LabelingRules.tslabeling/labeling-rule-policy.tslabeling/labeling-rules.http.tslabeling/labeling-rules.sse.ts✅ Correct — the kebab form of what the file defines:
labeling/labeling-rules.ts → LabelingRuleslabeling/labeling-rules-repo.ts → LabelingRulesRepolabeling/labeling-rules.test.ts → the tool suffixlabeling/http.tsSource: notes/01-code-organization/file-structure.md · Decision, axis 2 (amended)
No fixed file set, no depth limit
Section titled “No fixed file set, no depth limit”Impact: LOW avoids empty folders and premature rules
- A feature folder contains what that feature needs. No template, no required subfolders: a
backend feature has no
components/, a pure-logic one has nohooks/.testing.tsandfixtures/are optional members, present only when a feature needs them. - Where a recurring name does occur (say, the feature’s errors file), keep it consistent. It costs nothing, but it is not mandatory.
- Nesting follows the feature. Feature-first folders stay shallow on their own, so no maximum is written down.
❌ Incorrect — a mandatory template that every feature must fill:
labeling/ components/ lib/ hooks/ Services/ Layers/billing/ components/ lib/ hooks/ Services/ Layers/ ← mostly empty✅ Correct — each folder shaped by its feature:
labeling/ labeling-rules.ts labeling-rules-repo.ts policy.ts http.ts testing.tsbilling/ charge.ts invoices.ts invoices-repo.tsSource: notes/01-code-organization/file-structure.md · Decision, axes 3 and 4
Deferred
Section titled “Deferred”- A module in
shared/— trigger: a second feature needs it. - The facade pair
X.ts+X/for one module — trigger: a service in that module has more than 15 methods.