You open a new project and the first thing you do — before writing a single line of logic — is create a folder. Maybe it's called controllers/. Maybe it's called orders/. Either way, that quiet choice is already an architectural decision, and it will quietly shape everything that follows: how fast a new teammate finds their footing, how many files you touch when a single feature changes, and whether six months from now your codebase reads like your business or like your framework.
This post is a practical tour of the three main approaches — by-layer, by-feature, and screaming (by-domain) — with honest trade-offs at each company size. No single answer wins for every situation, but you'll leave with a clear mental model for choosing deliberately instead of by default.
Why folder structure is an architectural decision, not bikeshedding
People dismiss folder structure as "just organization". It's much more than that. The way you group code controls three things that turn out to matter enormously:
- Coupling. When code that changes together lives far apart, every small update turns into a cross-folder expedition. When it lives together, related changes cluster in one place.
- Discoverability. A developer unfamiliar with your codebase will ask, "where is the code for billing?" The answer to that question — one folder, or five scattered folders — determines onboarding time.
- Blast radius. How many files must you open to ship a new feature, or to safely delete an old one? Structure either constrains that blast radius or lets it sprawl unchecked.
Think of your folder structure as a map. A good map groups things by how they relate in the real world — not by what material they're made of. Grouping files by type (controllers, models) is like sorting map icons by shape. Grouping by feature is like sorting by neighborhood. One of these helps you navigate; the other is technically correct but practically useless.
Conway's Law — the observation that software systems mirror the communication structures of the teams that build them — applies here too. If your team is organized around business capabilities (a payments squad, an orders squad), a by-feature structure will feel natural and enforce the right boundaries. A by-layer structure will fight you.
By layer: the familiar default
By-layer organization groups files by their technical role. A typical Node or Spring Boot project ends up looking something like this:
src/
├─ controllers/
│ ├─ OrderController.ts
│ ├─ UserController.ts
│ └─ BillingController.ts
├─ services/
│ ├─ OrderService.ts
│ ├─ UserService.ts
│ └─ BillingService.ts
├─ repositories/
│ ├─ OrderRepository.ts
│ ├─ UserRepository.ts
│ └─ BillingRepository.ts
└─ models/
├─ Order.ts
├─ User.ts
└─ Invoice.ts
Every web framework tutorial lands here. Rails does it, Spring Boot's starter does it, Express generators do it. That familiarity is its main asset.
Where by-layer shines:
- Small apps with two or three features — the structure is so flat it barely matters.
- Teams where everyone works across all features; the layer is the unit of specialization.
- Prototypes and internal tools where speed of initial setup beats long-term clarity.
Where by-layer hurts:
- A single feature is scattered across folders. Adding a "refund" feature means touching
controllers/,services/,repositories/, andmodels/. That's four folders for what is conceptually one thing. - Deleting a feature is an archaeology project. How do you know which files in
services/belong to billing versus orders? The folder name doesn't tell you. - Merge conflicts multiply. Every feature change touches the same layer-level files, causing collisions when two developers work in parallel.
By-layer works fine up to maybe 10–15 features. Beyond that, each top-level folder becomes a junk drawer: 40-file services/ folders, no clear ownership, and pull requests that touch files a reviewer has never seen before.
By feature: a change lives in one folder
By-feature organization groups files by the slice of the product they serve. The technical layers (controller, service, model) still exist — they just live inside each feature folder instead of at the top level:
src/
├─ orders/
│ ├─ OrderController.ts
│ ├─ OrderService.ts
│ ├─ OrderRepository.ts
│ └─ Order.ts
├─ billing/
│ ├─ BillingController.ts
│ ├─ BillingService.ts
│ ├─ BillingRepository.ts
│ └─ Invoice.ts
├─ auth/
│ ├─ AuthController.ts
│ ├─ AuthService.ts
│ └─ User.ts
└─ shared/
├─ database.ts
├─ logger.ts
└─ Money.ts
Where by-feature shines:
- A change lives in one folder. Adding a refund flow means adding files inside
billing/— no other folder is touched. The blast radius is small and contained. - Deleting a feature is simple. Remove the folder. If something outside still imports from it, your IDE will tell you immediately.
- Ownership is obvious. The payments team owns
billing/; the identity team ownsauth/. Monorepo tooling and code review rules can enforce this mechanically. - Onboarding is faster. A new hire working on orders reads only
orders/. They don't need to understand the whole codebase first.
Where by-feature needs care:
- Shared code placement is awkward. The
Moneytype is used by bothorders/andbilling/. Does it live inorders/? That feels wrong. It ends up inshared/— which can itself grow into a second junk drawer if nobody owns it. - Cross-feature dependencies need discipline. If
orders/OrderService.tsimports directly frombilling/BillingService.ts, you've created a coupling that the folder structure can't see. Teams use linting rules (eslint-plugin-import/no-restricted-paths) or module-boundary tooling (Nx, NestJS modules) to make these explicit.
Screaming Architecture: what does your top level say?
Uncle Bob (Robert C. Martin) coined the term Screaming Architecture in a famous blog post: "What does the architecture of your application scream? When you look at the top-level directory structure, and the source files in the highest-level package; do they scream: Health Care System, or Accounting System, or Inventory Management System? Or do they scream: Rails, or Spring/Hibernate, or ASP.Net?"
The insight is elegant: if your top-level folders are controllers/, models/, views/, your codebase is screaming MVC framework. The framework is a tool — it shouldn't be the first thing a reader encounters.
Screaming Architecture (also called by-domain) takes by-feature one step further. Not only are features at the top level — the names of those features are drawn directly from the business language (the Ubiquitous Language of Domain-Driven Design), not from technical roles:
// Screams "e-commerce platform"
src/
├─ catalog/ // the domain concept: a product catalog
├─ ordering/ // the domain concept: placing and tracking orders
├─ fulfillment/ // the domain concept: picking, packing, shipping
├─ payments/ // the domain concept: charges, refunds, invoices
└─ identity/ // the domain concept: accounts, auth, permissions
// Compare with the framework-screaming alternative:
src/
├─ controllers/ // screams "MVC"
├─ models/ // screams "ORM"
├─ views/ // screams "templating engine"
└─ services/ // screams "service layer pattern"
Inside each domain folder the internal layout is up to the team — and that's fine. The key promise is that the top level communicates the business, not the infrastructure. A new developer (or your CTO, or a domain expert from the business side) can read the top-level folders and understand what the system does without knowing how it's built.
Side-by-side comparison
Here is an honest appraisal across five practical dimensions:
| Dimension | By layer | By feature | By domain (screaming) |
|---|---|---|---|
| Discoverability | Low at scale — "where is the billing code?" has no fast answer | High — one folder per concept | Highest — folder names come from the business itself |
| Change locality | Poor — one feature change scatters across all layers | Good — changes cluster in one folder | Good — same as by-feature, plus explicit domain language |
| Onboarding speed | Slow — newcomers must understand the whole layer before touching anything | Fast — study one folder to work on one feature | Fastest — domain names guide understanding without prior context |
| Coupling risk | High — shared layers become implicit coupling surfaces | Medium — cross-feature imports are possible but visible | Low — explicit bounded contexts and enforced boundaries |
| Risk of "Big Ball of Mud" | High — layers become junk drawers as features multiply | Medium — shared/ folder can grow unbounded | Low — domain boundaries make mud visible early |
The pragmatic hybrid most good teams land on
In practice, the best teams don't pick one approach and apply it everywhere with religious consistency. They use a pragmatic hybrid: feature or domain folders at the top, small technical layers inside each one.
This hybrid buys you the best of both worlds:
- The top level screams your business — newcomers know immediately that this is an e-commerce app, not a generic MVC skeleton.
- Each feature is self-contained — a change to the billing flow touches only
billing/. - Technical layers still exist inside each folder, so developers who think in MVC or Clean Architecture layers aren't lost. They just look inside the feature folder.
- A
shared/folder holds truly cross-cutting concerns (aMoneyvalue object, a logger, a database connection). The key rule: if something is added toshared/, it should be extracted and owned deliberately — not dumped there as an escape hatch.
Some teams call it shared/, others common/, lib/, or core/. The name matters less than the discipline: treat it like a mini internal library. If a file keeps getting modified alongside a specific feature, it doesn't belong in shared — it belongs inside that feature.
At team scale: enforced boundaries and monorepos
As a codebase grows and multiple teams contribute, folder conventions alone aren't enough — a developer can always reach across a boundary with a quick relative import. The next step is making boundaries enforced:
- NestJS modules make inter-module dependencies explicit: you only get access to what another module
exports. An import that crosses a module boundary without going through the public API is a compile error. - Nx boundary rules let you declare which features or libraries are allowed to depend on which others, and then enforce it as a lint error in CI.
- Monorepos take this furthest: each domain (orders, billing, auth) becomes its own package with its own
package.json, its own tests, and explicit published exports. Turborepo and Nx are the dominant tools in the JavaScript ecosystem for managing this. The structure looks less like a folder tree and more like a graph of packages — but the underlying idea is identical: group by business capability, make boundaries explicit, enforce them mechanically.
The jump to a monorepo isn't always necessary. Many teams operate happily with a single package and a well-disciplined hybrid structure for years. Reach for monorepo tooling when cross-team coupling becomes a real pain — not preemptively.
Recommendations by company size
The right structure depends on where your organization is today. Here's a direct recommendation for each stage:
| Stage | Structure to reach for | Why | Real-world hint |
|---|---|---|---|
| Solo / pre-launch startup | By-layer or shallow by-feature | You have 3–5 features. Any structure works. Optimize for speed and simplicity. | Most Rails/Express tutorial defaults are fine here. Don't over-engineer. |
| Growing startup (5–20 engineers) | By-feature with a shared/ folder |
Features are multiplying. By-layer is starting to hurt. A change in payments shouldn't ripple through orders. | Many successful Series A companies live here: one repo, one service, features as top-level folders. |
| Scale-up (20–100 engineers) | Hybrid (domain on top, layers inside) + boundary lint rules | Teams own domains. Accidental cross-domain coupling is a real risk. Enforce boundaries in CI. | This is the Nx/NestJS module sweet spot. Shopify's core Rails app famously adopted strong domain separation at this stage. |
| Enterprise (100+ engineers) | Monorepo with domain packages, or independent services per bounded context | Package-level isolation, independent deployment, and versioned public APIs between domains become necessary. | Google, Meta, and Airbnb operate giant monorepos with strict ownership. Many enterprises go the other way: microservices per domain, which is screaming architecture taken to its logical conclusion. |
The most common structural mistake is not choosing the wrong approach — it's staying on the wrong approach too long. Teams start with by-layer, feel the pain at 20 features, and still don't refactor because "the structure works, technically". A one-afternoon folder reorganization at 15 features saves months of confusion at 50.
Seeing the difference: what lights up when you ship a feature
The most concrete way to feel the difference between by-layer and by-feature is to ask: which boxes light up when I add a new feature?
billing/ lights up. The rest of the codebase is structurally isolated from this change.This diagram is the clearest argument for feature-based grouping. "Blast radius" is not an abstract concept — it's the number of folders and files you open in a single pull request. Reviewers, CI pipelines, and git blame all work better when a feature change is one cohesive chunk, not a smear across every layer.
Key takeaways
- Structure is architecture. Your folder layout controls coupling, discoverability, and the blast radius of every future change.
- By-layer is familiar and fine for small apps, but becomes a junk drawer past 10–15 features.
- By-feature keeps changes local. A new feature touches one folder. Deleting a feature means deleting one folder.
- Screaming Architecture says the top level should describe your business, not your framework. If a non-technical stakeholder can read your folder names and understand what the system does, you've succeeded.
- The pragmatic hybrid most good teams land on: domain/feature names at the top, small technical layers inside each folder.
- Enforce boundaries as you scale. Folder conventions are suggestions; module systems, lint rules, and monorepo packages make them guarantees.
- Reorganize early — a one-afternoon folder refactor at 15 features avoids months of confusion at 50.
Now that you've seen how to structure code inside a single service, the natural next question is what to do when one service isn't enough. Read on: Monolith → Microservices.