Skip to content

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 one rm -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/server it 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’s no-restricted-imports does.
  • Any feature may import another feature’s service, errors, schemas and types. Another feature’s *-repo.ts and policy.ts are 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:

billing/charge.ts
import * as LabelingRulesRepo from "../labeling/labeling-rules-repo.ts" // lint error
import * as Policy from "../labeling/policy.ts" // lint error

✅ Correct — go through the owning feature’s service:

billing/charge.ts
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/domain and contracts packages. 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.json and 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/ domain
packages/domain/src/shared/ where a share between two domains goes

Source: notes/01-code-organization/file-structure.md · Decision, axis 1 (amended)

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.ts beside message.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.ts
labeling/labeling-rule-policy.ts
labeling/labeling-rules.http.ts
labeling/labeling-rules.sse.ts

✅ Correct — the kebab form of what the file defines:

labeling/labeling-rules.ts → LabelingRules
labeling/labeling-rules-repo.ts → LabelingRulesRepo
labeling/labeling-rules.test.ts → the tool suffix
labeling/http.ts

Source: notes/01-code-organization/file-structure.md · Decision, axis 2 (amended)

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 no hooks/. testing.ts and fixtures/ 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.ts
billing/ charge.ts invoices.ts invoices-repo.ts

Source: notes/01-code-organization/file-structure.md · Decision, axes 3 and 4

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