This codebase is organised by feature, not by kind. There is no components/ folder holding four hundred unrelated components, and no services/ folder that is really just a junk drawer with a job title.
src/
routes/ thin: routing, layout, loaders
feature/
billing/
api/ server functions
components/
lib/
workspace/
growth/
shared/ anything two features both need
The layout is the easy half. Plenty of projects have it and are still a knot. What makes it hold is a single rule.
Features may not import from each other
A file in feature/billing cannot import from feature/growth. If both need something, it moves to shared/. That is it.
This is enforced, not documented, because a rule that lives only in a README is a rule that survives until the first deadline:
'no-restricted-imports': ['error', {
patterns: [{
group: ['#/feature/*/*'],
message: 'Features may not import from other features. Move it to shared/.',
}],
}]
Why the constraint pays
You can delete a feature. Remove the folder, remove its routes, and the app still builds. That is only true if nothing else reached inside it. Being able to delete code is the clearest possible signal that a boundary is real.
Ownership is obvious. A change to referrals touches feature/growth and nothing else. Review scope is the folder.
shared/ stays honest. Because moving something to shared/ is the only escape hatch, every promotion is a deliberate act with a diff attached. Compare that to a utils/ folder, which grows by accident and is never audited.
What we got wrong
The rule caught us almost immediately. The growth feature needed requireAdmin, which lived in the admin feature's API module. The lazy fix is an import; the linter refused, which was the entire point. requireAdmin moved to shared/auth/middleware.ts and the admin feature now re-exports it for its own callers. Two minutes, and the boundary held.
The same thing happened with the Markdown pipeline. The help centre had it; the blog needed it. Rather than copying two hundred lines, it became shared/content/markdown.ts and both read from there. The second consumer is what tells you the abstraction is real.
When not to do this
If your app is one screen, this is ceremony. Feature folders start paying at roughly the point where two people can be surprised by each other's changes.
But do decide early. Retrofitting boundaries onto a codebase that has spent a year without them is a different, much worse project than starting with a linter rule that costs nothing.
Ada Whitfield
2 min read