Most codebases I've worked in for more than six months end up with the same problem somewhere. There's a utils/ folder, or maybe a helpers/ or a shared/, and over time it becomes the place things go when nobody wants to decide where they really belong. You open it a year later and find a date formatter sitting next to a function called getCurrentUserPermissions, a slugify, and an email validator nobody has touched in months. None of those things have anything to do with each other, and the folder isn't organising any of them.
The pattern I've used for years to deal with this is three folders instead of one, with a single test that decides which folder a helper belongs in. Every part of it has been written about for decades, but I haven't seen the assembly written down as a named pattern, so I call it Three-Tier Hoisting.
The three tiers
Where the folders sit depends on the framework, and there's no need to bury them under a lib/. In a Next.js app they sit near the top level, with each domain holding its own services and components:
generic/
product/
domain-one/services/
domain-two/services/
domain-two/component-one/
generic is code that could live in any repo and has nothing to do with this product. An array sort helper or a debounce is the kind of thing that belongs here. Drop it into a different codebase tomorrow and it would still make sense.
product is code that's specific to this product but used all over its own codebase. It wouldn't mean anything in another repo, but inside this one it's shared widely. Database access, transformers, mappers and drivers usually live here.
component or module specific is code used only where it sits. If one component is the only thing that touches a helper, the helper stays next to that component. It doesn't matter whether that location is a component, a module or anything else. The point is that exactly one place uses it.
The test for each tier
The test for generic is whether the code could drop into any repo without changes. If it imports a type called Tenet or a constant called PILLARS, it isn't generic, however generic the logic looks.
The test for product is whether the helper's name carries a word from the product's own language. get-tenet-data does. format-date doesn't. If a helper carries no domain word and only one component uses it, leave it where it is. If more than one component uses it and it still carries no domain word, it's generic.
The test for component-local is whether exactly one place uses it. If so, leave it there.
Imports flow upward only
generic/ <- product/ <- domain code
^ ^ |
|___________|_____________|
(imports)
Domain code can import from product and from generic. The product tier can import from generic. Generic can't import from either of the others. If any of those get reversed the build fails, because a lint rule enforces it rather than convention. ESLint's import/no-restricted-paths does this in a few lines of config. Bulletproof React documents the same one-way-import idea with its own enforcement, just with two tiers instead of three.
Once the rule is enforced, reverse couplings show up as build errors. You stop having code-review conversations about whether a particular import feels right, because the lint rule has already decided.
The missing middle tier
Most published folder structures skip this tier.
Feature-Sliced Design has shared/, which is effectively generic/ under another name, and then entities/, features/ and widgets/. Those upper layers are a UI composition hierarchy rather than a hoisting tier, so they don't do the job of product/. Bulletproof React has shared/ and features/ with nothing in between. Both are good projects and I've borrowed plenty from each, but neither has a named middle tier, and that's where a lot of the duplication in real codebases ends up hiding.
Without a product tier, a helper needed by three features has two homes available. The team can copy it into all three feature folders, which keeps the organisation tidy but introduces real duplication. Or the team can put it in one feature folder and import it sideways from the other two, which avoids the duplication but creates a coupling the folder structure says isn't there. Both happen all the time.
Robert C. Martin's Common Reuse Principle covers why this hurts. The CRP says things used together are packaged together. When three modules import the same helper, the helper belongs in a tier above all three of them, not buried inside one with sideways imports coming from the other two. The principle has been around for thirty years, but I haven't found anyone turning it into a named folder.
How the tiers grow
One thing people get wrong early on is letting the generic and product tiers fill up on their own. Those two tiers should only grow through deliberate hoisting. Don't create a new file in either folder because you happened to be looking for somewhere to put a helper. The file should appear because someone noticed a third caller for an existing helper and lifted it up a tier.
If generic/ has six files after a year, that doesn't mean the codebase is lean. It more likely means duplication is hiding inside individual modules and nobody has spotted it. A healthy generic folder grows steadily as the codebase grows.
I have to look for this actively. Every few weeks I'll search the codebase for a small helper I remember writing and find it copy-pasted into three feature folders. The hoist rarely starts on its own.
The counter-argument
The usual objection here is Sandi Metz's: premature abstraction costs more than duplication. Two functions that look alike today often turn out to be doing different things tomorrow, and an abstraction pulled from today's overlap distorts both callers as they change. Hoist too early and you pay twice, once to build the wrong abstraction and once to pull it back out. Kent C. Dodds makes the same point under the name AHA programming, letting the duplication show you the shape of what should really exist.
It's a real point, but it doesn't land against this pattern, because the rule of three already handles it. Every helper starts inside the module that needs it. The first time a second module reaches for the same helper, the decision is open, and the rule of three says it's fine to see something twice. By the third use, hoist it. Where the shape is genuinely unclear, where the two existing callers are using the helper for visibly different reasons, I leave it duplicated for one more round. The duplication is cheaper than the wrong abstraction, and the rule of three at the boundary is what stops the folder structure pushing you to lift too early.
The lint rules that hold it together
The folder structure on its own doesn't do anything. Two files in the same folder can be loosely coupled. Two files in opposite corners of the tree can share state through a singleton. The folders are a proxy for the underlying coupling, and the proxy only holds if a couple of rules are non-negotiable.
The import-direction rule (import/no-restricted-paths) needs to run in CI so that reverse couplings come out as build errors rather than soft conventions. The tier tests, the could-live-anywhere test for generic/ and the domain-language test for product/, can be checked by hand in code review, but they drift if they aren't automated. The folder structure and the lint rules together are what does the work. Neither half holds up on its own once delivery pressure picks up.
What it looks like after a few years
After a few years working with this shape, the utils/ dumping-ground problem stops turning up in new codebases I set up. Sideways imports between sibling features become rare, because they're build errors. New joiners can place a file correctly on their first day, because the test for each tier is operational rather than cultural. Coding agents follow the same rule if it's in the brief, because "default to local, hoist on the third caller, imports upward only" is the kind of instruction a model can read and apply without much hand-holding.
The one thing the pattern won't do is decide for you whether a given helper has earned the lift. That still needs a person spotting the third caller. Once the decision is made, the folder structure and the lint rules carry it forward without anyone having to police it.
I've written the longer version of this on Prickles, with the origins, the other counter-arguments, and the ESLint configuration I use to enforce the import-direction rule: Three-Tier Hoisting.
If you've worked with Feature-Sliced Design or Bulletproof React and felt the same missing tier, I'd be interested to hear how you've handled it in your own codebases.
Top comments (0)