renderpx

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.

The type-based scatter problemtsx
Loading...

Adding a Notifications feature:

6directories touched — spread across 6 separate folders
UserAvatar.tsx
ProductCard.tsx
NotificationBell.tsxNEW
NotificationList.tsxNEW
useUser.ts
useProduct.ts
useNotifications.tsNEW
userService.ts
productService.ts
notificationService.tsNEW
formatDate.ts
formatNotification.tsNEW
user.ts
product.ts
notification.tsNEW
routes.ts
notificationTypes.tsNEW
New files
Existing folders touched

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

Does this code belong to a specific feature?
  • 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/
Can I delete this feature by deleting one folder?
  • 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?

❌ Type-Based — tells you the tech stack
src/
components/
hooks/
services/
utils/
types/
✅ Feature-Based — tells you what the app does
src/features/
products/
cart/
checkout/
notifications/
users/

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.

The colocation heuristic in practicetsx
Loading...
Explicit module contracts via index.tstsx
Loading...

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.

StructureTeamScaleFeaturesUse WhenAvoid When
Flat1–2 devs< 15 files1–3 featuresPrototypes, demos, early-stage appsAny app you expect to grow
Type-Based1–5 devs15–50 files3–8 featuresSmall apps with stable feature set; utility/shared librariesApps where features are added or deleted regularly
Feature-Based3–15 devs50–500 files8–30 featuresMost production apps — the right default for medium-to-large codebasesApps so small that the overhead isn't justified
Module Contracts5–30 devs100+ files10+ featuresTeams where cross-feature coupling is causing bugs; ESLint enforcedSmall teams — the ceremony outweighs the benefit; enforce gradually
Route ColocationAnyAny Next.js appAnyNext.js App Router — the framework's native organization patternNon-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

Starter

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

tsx
Loading...

Why this works

Zero friction — start building immediately. No mental overhead deciding where a file belongs. Everything is one directory away.

When this breaks

At ~15–20 files, scrolling through a flat list becomes painful. Related files (ProductCard.tsx, useProduct.ts, productService.ts) are scattered alphabetically. There's no way to tell which files belong to which feature.

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.

The approach: Don't migrate everything at once. Create a 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.
The rule we followed: "If you're touching a file, move it to its feature folder while you're there." No big-bang migration. No dedicated refactor sprint. Just consistent incremental movement.
What surprised us: The migration revealed three components that no one was sure which feature owned. They were genuinely shared — we promoted them to shared/components/. The uncertainty itself was a sign we hadn't thought clearly about feature boundaries.
What I'd do differently: add the ESLint boundary rule from day one. Within a week of the migration, two developers had started importing directly from feature internals. The rule would have caught it immediately.

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.

What happened: A 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.
The fix: Switched to direct imports for the heaviest components and kept barrel exports only for lightweight primitives. Added "sideEffects": false to package.json to help the bundler tree-shake correctly. Also audited the barrel to remove anything that imported a large dependency.
The rule that stuck: Barrel files are a developer experience tool, not a performance optimization. If a barrel re-exports something heavy (charts, rich text editors, map libraries), don't put it in the barrel.
Enforcing module boundaries with ESLintjs
Loading...

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.

Before/after: adding a notification typetsx
Loading...

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

Organizing by type because it 'feels' more structured

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.

Creating a shared/ folder and putting everything in it

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 everywhere as a reflex

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.

Treating code organization as a one-time decision

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.