modules-and-the-graduation-rule
a folder created for a concept with no private companions, or a folder's internals reached from outside it
The argument
Modules & the Graduation Rule
A module is a unit of code with a public API and private internals. TypeScript enforces privacy at the file level (non-exported = invisible); folder-level boundaries are convention the repo may enforce with tooling.
Every concept starts as a file and earns its folder:
- File-module (default): a single file holding one exported item plus non-exported helpers. The compiler enforces the boundary for free.
- Folder-module (graduated): when a concept needs private companions — its own utils, types, or constants that serve only it — it graduates to a folder holding the concept and its companions.
- Born folders: features, route modules, and screens are inherently multi-file and start as folder-modules.
The trigger is mechanical: needs private companion files → folder; doesn't → file. Never create folder ceremony for a one-file concept.
Borderline cases are decided by the companion test: does any of the concept's files serve only the concept itself? No → the concept is primitives; its files belong in common/<type>/. Yes → it is a module. This applies to shared code too: a shared concept with private companions graduates OUT of common/ into its own module (folder-structure.md).
Boundary rules for folder-modules:
- Every import names the file that declares what it imports. A folder carries no
index.ts; only a package's entry does, and code inside the package never imports through it. - Code outside a folder-module imports the files it is meant to use — the concept's own file, and what that file's callers need — never the companions that serve only the concept
- Inside a module, imports between its files are correct
- Tests target the module's public files; its companions are covered through them (a
.unit.test.tsbeside a file marks it as a boundary; files under a module'scommon/have none of their own) - Test imports obey the same boundary: a test OUTSIDE a module imports only its public files, never its companions — including in repos that keep tests in a separate directory. (A test living beside its file is inside the module; it imports that file directly.)
The rule is recursive — a graduated component folder inside a feature folder is a module within a module.
The proof
interface ConstructorParams {
perMinute: number;
}
// A folder, a barrel, and nothing private to hide behind them — ceremony for a
// concept that is still one file.
export class RateLimiter {
private remaining: number;
constructor({ perMinute }: ConstructorParams) {
this.remaining = perMinute;
}
take(): boolean {
const allowed = this.remaining > 0;
if (allowed) {
this.remaining -= 1;
}
return allowed;
}
}
interface ConstructorParams {
perMinute: number;
}
// One file until it needs private companions. The compiler already enforces the
// boundary: nothing here is exported but the class itself.
export class RateLimiter {
private remaining: number;
constructor({ perMinute }: ConstructorParams) {
this.remaining = perMinute;
}
take(): boolean {
const allowed = this.remaining > 0;
if (allowed) {
this.remaining -= 1;
}
return allowed;
}
}
Turn it down
Both lines go in your lightsout.config.json.
"standards-checks": { "modules-and-the-graduation-rule": "advisory" }"standards-checks": { "modules-and-the-graduation-rule": "off" }