Code Organization & Boundaries
Where do files live — and when does it matter?
The Problem
Every codebase starts organized. Then it grows. Then someone adds a feature that touches six directories, and the file called utils/formatNotification.ts gets added to a folder that already has twenty other unrelated utilities in it.
The default instinct is to organize by type: components/, hooks/, utils/, services/. It feels correct — components go with components, hooks go with hooks. But it creates a problem that only becomes visible when the codebase grows: a single product feature is now scattered across five directories.
The consequence shows up when you try to delete a feature. Instead of removing a folder, you're running grep across the codebase, hunting for files that might be safe to delete—and hoping you got them all.
Adding a Notifications feature:
The Framework: One Heuristic, Two Questions
The single most useful organizing principle in frontend architecture:
Code that changes together should live together.
— The Colocation Principle
- Yes — one feature uses it → Lives inside features/that-feature/
- Two or three features share it → Lives in shared/ or components/
- Everything uses it → Lives at root: lib/, utils/
- Yes → ✅ Good structure — feature is self-contained
- No — I'd need to grep → ⚠️ Feature is leaking across the codebase
- I'm not sure → 🔍 Map it — you'll find implicit coupling
The Screaming Architecture test
Open your src/ folder. Does it "scream" what the app does? Or does it just tell you it's a React app?
Module contracts: making dependencies explicit
Feature-based organization solves the scatter problem. But it introduces a new one: without discipline, Feature A starts importing directly from Feature B's internals. When B refactors, A breaks. The fix is an explicit public API.
Decision Matrix
Organization scales with team size and app complexity. Picking the right structure for the wrong scale is as bad as having no structure.
| Structure | Team | Scale | Features | Use When | Avoid When |
|---|---|---|---|---|---|
| Flat | 1–2 devs | < 15 files | 1–3 features | Prototypes, demos, early-stage apps | Any app you expect to grow |
| Type-Based | 1–5 devs | 15–50 files | 3–8 features | Small apps with stable feature set; utility/shared libraries | Apps where features are added or deleted regularly |
| Feature-Based | 3–15 devs | 50–500 files | 8–30 features | Most production apps — the right default for medium-to-large codebases | Apps so small that the overhead isn't justified |
| Module Contracts | 5–30 devs | 100+ files | 10+ features | Teams where cross-feature coupling is causing bugs; ESLint enforced | Small teams — the ceremony outweighs the benefit; enforce gradually |
| Route Colocation | Any | Any Next.js app | Any | Next.js App Router — the framework's native organization pattern | Non-Next.js projects; apps with many routes sharing components |
Progressive Examples
The same e-commerce app as it grows from a prototype to a production-grade codebase. Each step shows what changes and why.
Example 1: Flat
StarterEverything at root — works until ~15 files
The natural starting point for every project. All files live at the same level. There are no rules, no abstractions — just code. This works perfectly at small scale and requires zero upfront decisions.
Why this works
When this breaks
Production Patterns
Migrating from type-based to feature-based without a rewrite
A 50-component codebase in a flat components/ directory. Every feature needed touching 4+ directories for even minor changes. Team velocity was grinding down.
features/ directory and start routing new features there. When touching an existing feature, move its files into the feature folder as part of that PR. Over 6 weeks, the codebase naturally migrated.shared/components/. The uncertainty itself was a sign we hadn't thought clearly about feature boundaries.The barrel file that caused a 40% bundle size regression
Module contracts via barrel exports are powerful. But barrel files have a well-known pitfall: if your bundler can't tree-shake them, you import one component and get the entire barrel.
components/ui/index.ts re-exported 60+ components. A login page imported Button from it. The bundler included every component in the barrel — including a chart library dependency — in the login bundle. Bundle size went from 120kb to 210kb overnight."sideEffects": false to package.json to help the bundler tree-shake correctly. Also audited the barrel to remove anything that imported a large dependency.Inheriting a type-based codebase: the incremental migration
You've joined a team with three years of accumulated components/, hooks/, services/, utils/. The instinct is to propose a migration. The right instinct is to audit first: count how many files you touch for a single feature addition. If the answer is more than three, you have the business case. If it's one or two, the pain may not justify the disruption.
When the migration is worth it: don't declare a refactor sprint. Create a features/ directory and route all new development there immediately. Old code migrates opportunistically — when you're touching a file anyway, move it. Barrel files in the old structure can re-export from new locations during the transition. The migration is done when new code stops landing in components/ — not when every old file has been moved.
A Real Rollout
What it actually looks like to change how a team organizes code — with engineers who have muscle memory, a product that can't stop shipping, and a junior dev who broke production.
Context
Three-year-old B2B app with a type-based folder structure. Four engineers, fast-moving roadmap. Adding any new feature required touching four to six files across four directories. The codebase had grown to the point where onboarding a new engineer took a week just to understand where things lived.
The problem
A junior engineer broke the notifications feature while adding an unrelated auth change — the same files overlapped. hooks/useNotifications.ts and hooks/useAuth.ts shared a dependency, and a change in one cascaded into the other in a way no one caught in review. The notifications feature was down for two hours before someone connected the PR to the incident. The coordination cost was becoming a tax on every sprint: features that should have been isolated were entangled by the folder structure.
The call
Proposed feature-based folders for all new development, no migration sprint. Old structure stays; new features land in features/<name>/. Existing files migrate opportunistically — when you touch a file anyway, move it. The only rule enforced immediately: new files go in feature folders. I also proposed adding the ESLint boundary rule from day one — that's the call I'd have made even if the team pushed back, because without it the migration just creates two parallel messes instead of one.
How I ran it
Got pushback from one engineer who preferred the mental model of “all hooks in one place.” The diff that changed the conversation: adding a new notification type in the old structure (six files, four folders) vs. the new structure (two files, one folder). The ESLint rule was the hardest sell — it felt like extra ceremony. I scoped it narrowly: it only flagged imports from feature internals, not between features. After the first time it caught a cross-feature import that would have caused a circular dependency, the team stopped asking why it existed.
The outcome
After three months, new features shipped without cross-feature file collisions. The notification feature that prompted the change lives in features/notifications/ and hasn't caused an adjacent-feature incident since. Junior engineers onboard faster because the scope of a feature is visible immediately — one folder, all related code. We never finished migrating every old file. We didn't need to: the problem was solved at the growth boundary, not the historical one.
Common Mistakes & Hot Takes
Type-based organization (components/, hooks/, utils/) isn't structure — it's alphabetical grouping with extra steps. It answers 'what is this?' when the question you actually need answered is 'what does this belong to?' Feature-based organization feels messier at first because you have to make a decision. That's the point. Making that decision once is better than making it implicitly every time you search for a file.
I've seen codebases where 80% of the components are in shared/. At that point, shared/ is just components/ renamed. The rule is strict: shared/ is for code that is actually shared — used by three or more features. Two features sharing something is usually fine as a direct import. If you find yourself asking 'is this shared enough?', the answer is probably no.
Barrel files (index.ts that re-exports everything) feel tidy. They are also one of the most common causes of accidentally-large bundles I've seen in production. The issue is that bundlers can't always tree-shake through barrels correctly, especially with mixed ESM/CJS code or side effects. Use barrel files strategically for public module APIs, not as a way to avoid typing longer import paths.
The right structure at 5 engineers is wrong at 25. The right structure for a monolith is wrong for a monorepo. Good engineers revisit structure when it starts causing friction — when PRs consistently touch too many directories, when new team members can't find things, when deleting a feature takes a day instead of an hour. Code organization is a living thing, not a founding principle.