Three-Tier Hoisting
Default to local. Earn each tier upward.
Not the JavaScript kind. The refactoring kind: a helper starts in the module that needs it and is lifted to a higher tier as it earns broader reuse. Default to local; earn each tier upward.
Opinion
I've spent enough years watching utils/ folders become the codebase's attic to know what a missing middle tier costs. Feature-Sliced Design and Bulletproof React both nail the bottom of the stack (a shared/ drawer for product-agnostic code) and both stop there.1FSD: “Reusable functionality, especially when it's detached from the specifics of the project / business — business domains do not exist in Shared.” Bulletproof React: “The code should flow in one direction, from shared parts of the code to the application.” Both define `shared/`; neither defines a middle tier. Everything domain-specific lands in feature folders, which means a helper used by three features either gets duplicated three times, or sneaks into one feature folder and gets imported sideways from the other two. The first option lies about DRY. The second lies about coupling.
The fix is the named middle tier. lib/product/ is where get-tenet-data and build-tenet-page-metadata live: domain-aware, reused across modules, not so generic that the helpers would survive being shipped to another product. Robert C. Martin's Common Reuse Principle supplies the reasoning: classes used together are packaged together.2Clean Architecture (Prentice Hall, 2017), Ch. 13: Common Reuse Principle — “Classes that are used together are packaged together.” The reasoning behind `lib/product/` as a named tier. No published architecture I've found turns CRP into a named folder. The unnamed middle tier is the bug; Three-Tier Hoisting is the fix I've been working with for years.
The second sharp edge is the metric. lib/generic/ and lib/product/ can only grow through deliberate hoisting. Nobody accidentally writes lib/generic/array/sort-by.ts; the file appears because a contributor noticed three call sites and lifted it. Six files in lib/generic/ after a year of work does not mean the codebase is lean; it means duplication is hiding in modules.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Default to local. Earn each tier upward. A helper starts next to its consumer; on the third use it hoists — to `lib/product/` if it names a domain term, to `lib/generic/` if it would compile after `npm publish`. Imports flow upward only. /tenet/three-tier-hoisting/A2
AI eyes only
Rule: default to module-local. Hoist to lib/product/ on the third caller. Hoist to lib/generic/ only when the use is product-agnostic.
Reject: dropping new helpers into the file you happen to be editing. Reject: imports from sibling features. Reject: re-emitting a utility you could find via grep.
Generate: place new helpers next to the only caller. Promote to lib/product/<domain>/ on the third caller. Promote to lib/generic/ when the function names no product concept.
Diagnostic: imports flow upward only. Sibling-feature imports are the signal to hoist; generic imports of product types are the signal to demote.
Why?
- Defaulting to module-local removes the “where do I put this?” tax on every new helper. The decision is made: it lives next to the consumer until a second consumer earns the move.
- The lift-to-npm test is binary. If the file would compile after
npm publishwith no edits, it belongs inlib/generic/. Reviewers stop arguing taste; they ask the test. - Imports flow upward only — module to
product/togeneric/. The graph direction is the design, lint-enforced viaimport/no-restricted-paths. No cycles, no surprise reverse couplings. - The size of
lib/generic/andlib/product/is a leading DRY indicator. Nobody accidentally writes a hoisted helper; if those folders are thin after a year, the codebase is quietly carrying duplication. - Pairs with F1 Single Responsibility: when a function is extracted for SRP, the tier it lands in falls out of the lift-to-npm test, not a coin flip.
- Operationalises S1 Wait for Three. First write: keep it local. Second use: copy or hoist (your call). Third use: hoisting is forced. The rule is no longer a vibe.
- Coding agents pick targets badly without structure — they dump shared logic in whichever module they edited last. Three named tiers and an upward-only import rule give an agent a decision tree it can't fudge.
Origins
The instinct is older than any of its names. The Rails community has used lib/ since the early 2000s for “code that would make sense even if your application was ported to the console or an Android app”: that sentence is the lift-to-npm test verbatim, expressed twenty years earlier and never given a name beyond folk knowledge.7“What Code Goes in the Lib/ Directory?” (2012). The Rails-community lift-to-npm test verbatim, expressed thirty years before this dossier and never given a name beyond folk knowledge. Three-Tier Hoisting names what was tribal and adds the middle tier Rails never formalised.
The first published cousin is Martin Fowler's Pull Up Method from Refactoring (1999, 2nd edition 2018): the OOP-inheritance form of the same action, code shared by subclasses moves to the superclass.8Refactoring, 2nd ed. (Addison-Wesley, 2018). Pull Up Method / Pull Up Field — code shared by subclasses moves to the superclass. The OOP-inheritance form of the same metaphor; canonical source for the word “pull up” in this sense. Three-Tier Hoisting is the file-organisation form of the same metaphor: duplicated logic moves to a higher folder. React's “lifting state up” doctrine is the same instinct expressed at runtime: when two components need the same state, the state is lifted to their common ancestor.9“Sharing State Between Components” (formerly “Lifting State Up”). The runtime parallel: when two components need the same state, the state is lifted to their common ancestor.
Robert C. Martin's Common Reuse Principle is the load-bearing reason lib/product/ needs to exist as a tier: classes used together are packaged together.2Clean Architecture (Prentice Hall, 2017), Ch. 13: Common Reuse Principle — “Classes that are used together are packaged together.” The reasoning behind `lib/product/` as a named tier. When several modules import the same product helper, the helper graduates out of any one module into the tier they all already depend on. Without a product/ tier, CRP forces the helper into one arbitrary module which then becomes an implicit dependency of all the others. Martin's Stable Dependencies Principle supplies the direction: the generic/ → product/ → module gradient is naturally a stability gradient (generic/ changes least, modules change most), so the one-way import rule falls out automatically.10Clean Architecture (2017), Ch. 14: Stable Dependencies Principle. Dependencies must flow toward more-stable components — the import-direction rule for `generic/` → `product/` → module.
Feature-Sliced Design (~2018) and Bulletproof React (Alickovic, 2020) are the closest published parallels. FSD's shared/ layer is described as “reusable functionality, especially when it's detached from the specifics of the project / business”, which is lib/generic/ exactly.1FSD: “Reusable functionality, especially when it's detached from the specifics of the project / business — business domains do not exist in Shared.” Bulletproof React: “The code should flow in one direction, from shared parts of the code to the application.” Both define `shared/`; neither defines a middle tier. Bulletproof React's rule that “the code should flow in one direction, from shared parts of the code to the application”, lint-enforced via import/no-restricted-paths, is the upward-only import rule under a different name. Three-Tier Hoisting borrows from both, names the middle tier, and adds the growth-as-DRY observation neither has. Every piece — three named tiers, one-way imports, rule of three at the boundary, the lift-to-npm and ubiquitous-language tests — has a precedent. The assembly does not; the shape, the metric, and the discipline around the middle tier are mine.
Quotes
Classes that are used together are packaged together.
Duplication is far cheaper than the wrong abstraction.
Reusable functionality, especially when it's detached from the specifics of the project / business... business domains do not exist in Shared.
You have a method that's identical on subclasses. Move it to the superclass.
Evidence
Twenty external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01Defines `shared/` as reusable functionality detached from project specifics — the closest published equivalent of `lib/generic/`. FSD has no middle tier between `shared/` and the feature folders, which is the gap Three-Tier Hoisting fills.
- 02“The code should flow in one direction, from shared parts of the code to the application” — lint-enforced via `import/no-restricted-paths`. Same upward-only direction; same gap as FSD on the middle tier.
- 03“Classes that are used together are packaged together.” The reasoning the middle tier is built on: when several modules import the same product helper, the helper graduates out of any one module into the tier they all share.
- 04Dependencies must flow toward more-stable components. The `generic/` → `product/` → module gradient is a stability gradient by construction, so the upward-only import rule falls out for free.
- 05Code shared by subclasses moves to the superclass — the inheritance form of the same metaphor. The vocabulary “pull up” descends from here; Three-Tier Hoisting is the file-system form of the same refactor.
Eighteen sources across supports, qualifiers and opposers. The supporters cluster on the upward-only direction: Feature-Sliced Design, Bulletproof React, Martin's component-cohesion canon (CRP + SDP), and Fowler's Pull Up Method. The qualifiers further down carry the steelman the reply has to address: premature hoisting is the wrong abstraction. The opposers split between “folders are a brittle proxy for coupling” and “FSD already does this” (the novelty challenge). Both are answered below.
Examples
// Before: lib/utils/ mixes Tenet-aware and string-only helpers. No tier, no direction.// lib/utils/to-slug.ts:export function toSlug(value: string): string { return value.toLowerCase().replace(/\s+/g, "-");}// lib/utils/is-tenet-implemented.ts (same folder!):import { Tenet } from "@/lib/utils/tenet";export function isTenetImplemented(tenet: Tenet): boolean { return tenet.implementedAt !== null;}
// After: generic stays generic, product stays product. Imports flow upward only.// lib/generic/string/to-slug.ts (zero domain):export function toSlug(value: string): string { return value.toLowerCase().replace(/\s+/g, "-");}// lib/product/tenet/get-tenet-slug.ts (Tenet-aware):import { toSlug } from "@/lib/generic/string/to-slug";export const getTenetSlug = (tenet: Tenet): string => toSlug(tenet.title);
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| import/no-restricted-paths | eslint-plugin-import | any import that crosses a tier boundary in the wrong direction — generic importing product, product importing components, etc. |
| import/no-cycle | eslint-plugin-import | import cycles inside any tier — the symptom of a missing extraction or an SRP failure that hoisting has not yet caught. |
| import/no-self-import | eslint-plugin-import | a file importing itself, which usually means a barrel file is being abused. |
| import/no-internal-modules | eslint-plugin-import | deep imports into a module's internals when only the module's barrel was meant to be public — the inverse problem to direct imports, configurable per tier. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
plugins: { import: importPlugin },
settings: {
'import/resolver': { typescript: { project: './tsconfig.json' } },
},
rules: {
'import/no-restricted-paths': ['error', {
zones: [
{
target: './src/lib/generic',
from: ['./src/lib/product', './src/components', './src/app'],
message: 'lib/generic must not import from product, components, or app — generic is portable.',
},
{
target: './src/lib/product',
from: ['./src/components', './src/app'],
message: 'lib/product must not import from components or app — product is shared, not consumer-coupled.',
},
{
target: './src/components',
from: ['./src/app'],
message: 'components must not import from app routes.',
},
],
}],
'import/no-cycle': ['error', { maxDepth: 10 }],
'import/no-self-import': 'error',
}
});AI rules
.cursor/rules/a2-three-tier-hoisting.mdc---
description: Prickles A2 — Three-Tier Hoisting
globs: "**/*.{ts,tsx,js,jsx,py,java,php}"
alwaysApply: false
---
## Prickles A2 — Three-Tier Hoisting
Default to local. A new helper lives next to the consumer until a second consumer earns the move.
On the third repeat, hoist. To `lib/generic/` if the file would compile after `npm publish` with no edits; to `lib/product/` if it still names a Tenet, a Pillar, or any other word from the product's ubiquitous language.
Imports flow upward only. A module may import from `product/` and `generic/`; `product/` may import from `generic/`; `generic/` imports neither. Reverse the direction and the build fails.
If the abstraction's shape is unclear after two uses, leave it duplicated for one more iteration. Premature hoisting costs more than the duplication it would eliminate.Repo layout, CI, and ESLint wiring for these paths live on /implementation — not repeated on every tenet.
Counter-argument
The strongest steelman is Sandi Metz's.3“The Wrong Abstraction” (2016): “Duplication is far cheaper than the wrong abstraction.” The strongest steelman against any rule that encourages lifting; carved out via the rule of three. Premature hoisting is more expensive than duplication: two functions that look alike today often diverge in different directions tomorrow, and the abstraction extracted from today's overlap becomes a cage that distorts both callers. Hoist too early and the cost is paid twice: once to build the wrong abstraction, once to dismantle it. Kent C. Dodds frames the same point as “AHA programming”: avoid hasty abstractions; let the duplication surface the shape.4“AHA Programming” (2019): “Avoid Hasty Abstractions.” Restates Metz operationally; argues for prefer-duplication-until-the-shape-is-clear, which is exactly the rule-of-three trigger. The implication for Three-Tier Hoisting is sharp: a folder structure that encourages lifting can encourage lifting too soon.
Counter-argument retort
Metz is right about premature abstraction; the rule of three is the carve-out. Every helper starts inside the module that needs it. The first time a second module reaches for the same helper, the decision is open: copy (rule of three says fine, the same helper has been seen twice) or hoist. By the third repeat the answer is forced. Where the shape is genuinely unclear (two callers using the same helper for visibly different reasons) the dossier's explicit instruction is to leave it duplicated for one more iteration. The wrong abstraction costs more than the duplication it was eliminating; this rule agrees and refuses to reach for the tier before the duplication has surfaced which tier is right.3“The Wrong Abstraction” (2016): “Duplication is far cheaper than the wrong abstraction.” The strongest steelman against any rule that encourages lifting; carved out via the rule of three.
The Ousterhout-style objection (folder hierarchies are a brittle proxy for coupling) is sharper.5A Philosophy of Software Design (2018, 2nd ed. 2021). Argues folder hierarchies are weak proxies for the real concern (interface depth, cognitive load). The reply: the proxy is robust when the import-direction rule is lint-enforced and the tier test is operational. Folders can lie. Two files in the same folder may be loosely coupled; two files in opposite corners of the tree may share state through a singleton. The reply is that the proxy is robust when two things are true: the import-direction rule is lint-enforced (so reverse couplings show up as build errors, not vibes), and the tier test is operational (the lift-to-npm test for generic/, the ubiquitous-language test for product/). Both are concrete, both are checkable; the proxy stops being brittle when the test it stands on is a script.
The novelty challenge (“Feature-Sliced Design and Bulletproof React already do this, this is just renaming”) is the one I take most seriously, because both projects are excellent and explicitly cited as influences. The honest answer is that neither has the middle tier. FSD's shared/ is generic/ exactly; its upper layers ( entities/, features/, widgets/) are a UI composition hierarchy, not a cross-module hoisting tier. Bulletproof React has the same gap: shared/ and features/, nothing in between. Martin's CRP supplies the reasoning a middle tier needs;2Clean Architecture (Prentice Hall, 2017), Ch. 13: Common Reuse Principle — “Classes that are used together are packaged together.” The reasoning behind `lib/product/` as a named tier. no published architecture I've seen turns it into a named folder. The growth-as-DRY observation is not in either either.
The genuine residue is the Evans confusion. Earlier drafts of this rule cited Generic Subdomain from Domain-Driven Design; that citation was wrong. Evans's Generic Subdomain is a category in strategic domain modelling: capabilities every business needs but is not its specialty.6Domain-Driven Design (Addison-Wesley, 2003), Ch. 15: Generic Subdomain. A strategic-design category — capabilities every business needs but isn't its specialty (money, time zones, identity, accounting). Cited only to disambiguate; this is not what Three-Tier Hoisting is. Three-Tier Hoisting is not domain modelling; it is file-and-import organisation by domain awareness. The word “generic” matched; the underlying concept did not. The Evans citation has been removed; see A6 Bounded Contexts for the actual DDD overlap.
Notes
- [1]Feature-Sliced Design / Bulletproof React — FSD: “Reusable functionality, especially when it's detached from the specifics of the project / business — business domains do not exist in Shared.” Bulletproof React: “The code should flow in one direction, from shared parts of the code to the application.” Both define `shared/`; neither defines a middle tier.
- [2]Robert C. Martin — Clean Architecture (Prentice Hall, 2017), Ch. 13: Common Reuse Principle — “Classes that are used together are packaged together.” The reasoning behind `lib/product/` as a named tier.
- [3]Sandi Metz — “The Wrong Abstraction” (2016): “Duplication is far cheaper than the wrong abstraction.” The strongest steelman against any rule that encourages lifting; carved out via the rule of three.