Skip to content
All posts

Feature folders, and the one rule that makes them work

Grouping code by feature is easy. Keeping features from reaching into each other is the part that needs a linter.

Ada Whitfield

2 min read

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

Subscribe

Keep reading