Case file — A2

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.

ByAdam LewisPublished3 May 2026Reading12 minVersionv1.0ConfidenceHigh
§0b

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.1Feature-Sliced Design / Bulletproof ReactFSD: “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.2Robert C. MartinClean 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
§0c

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.

§0d

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 publish with no edits, it belongs in lib/generic/. Reviewers stop arguing taste; they ask the test.
  • Imports flow upward only — module to product/ to generic/. The graph direction is the design, lint-enforced via import/no-restricted-paths. No cycles, no surprise reverse couplings.
  • The size of lib/generic/ and lib/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.
The receipts
Origins, quoted passages, evidence, the strongest counter-argument and the reply.
§1

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.7Code Climate Blog (Rails community)“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.8Martin FowlerRefactoring, 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.9React core team“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.2Robert C. MartinClean 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.10Robert C. MartinClean 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.1Feature-Sliced Design / Bulletproof ReactFSD: “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.

§2

Quotes

Classes that are used together are packaged together.

Robert C. Martin · Clean Architecture (2017), Ch. 13

Duplication is far cheaper than the wrong abstraction.

Sandi Metz · The Wrong Abstraction (2016)

Reusable functionality, especially when it's detached from the specifics of the project / business... business domains do not exist in Shared.

Feature-Sliced Design · Layers reference

You have a method that's identical on subclasses. Move it to the superclass.

Martin Fowler · Refactoring 2e (2018), Pull Up Method
§3

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.

  1. 01
    FSD core team · 2018–
    Defines `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.
  2. 02
    Alan Alickovic · 2020
    “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.
  3. 03
    Robert C. Martin · 2017
    “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.
  4. 04
    Robert C. Martin · 2017
    Dependencies 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.
  5. 05
    Martin Fowler · 2018
    Code 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.

§4

Examples

Viewing: TypeScript.
Avoid
Filelib/utils/index.ts
// 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;}
Prefer
Filelib/{generic,product}/...
// 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);
§4b

Enforcement

Viewing: TypeScript.

Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.

RuleToolCatches
import/no-restricted-pathseslint-plugin-importany import that crosses a tier boundary in the wrong direction — generic importing product, product importing components, etc.
import/no-cycleeslint-plugin-importimport cycles inside any tier — the symptom of a missing extraction or an SRP failure that hoisting has not yet caught.
import/no-self-importeslint-plugin-importa file importing itself, which usually means a barrel file is being abused.
import/no-internal-moduleseslint-plugin-importdeep 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',
  }
});
§4c

AI rules

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

§5

Counter-argument

Counter

The strongest steelman is Sandi Metz's.3Sandi 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. 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.4Kent C. Dodds“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.

§6

Counter-argument retort

Reply

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.3Sandi 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.

The Ousterhout-style objection (folder hierarchies are a brittle proxy for coupling) is sharper.5John OusterhoutA 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;2Robert C. MartinClean 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.6Eric EvansDomain-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.

§7

Notes

  1. [1]Feature-Sliced Design / Bulletproof ReactFSD: “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. [2]Robert C. MartinClean 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. [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.
Disagree? Found a hole in the argument? Take issue with this tenet →
Last revised: 2026-04-27