Nguyen Le Phong

Foundations of Software ArchitecturePart 4 of 6

Structuring a Codebase: By Layer, By Feature, or By Domain?

Should your top-level folders be controllers/services/models, or orders/billing/auth? The choice quietly shapes how your codebase grows. A practical tour of by-layer, by-feature, and screaming architecture — with the trade-offs at each company size.

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.
The mental model

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
By-layer structure: every technical layer gets its own top-level folder. Adding a feature means adding files to all four folders.

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/, and models/. 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.
The scaling cliff

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
By-feature structure: each product concept owns its own folder. Adding a refund feature adds one folder — nothing else moves.

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 owns auth/. 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 Money type is used by both orders/ and billing/. Does it live in orders/? That feels wrong. It ends up in shared/ — which can itself grow into a second junk drawer if nobody owns it.
  • Cross-feature dependencies need discipline. If orders/OrderService.ts imports directly from billing/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.

Hybrid structure: domain/feature folders on top, technical layers inside each. PRAGMATIC HYBRID — domain on top, layers inside src/ orders/ billing/ shared/ controller.ts service.ts repository.ts Order.ts controller.ts service.ts repository.ts Invoice.ts Money.ts logger.ts database.ts
The hybrid most teams converge on: domain/feature at the top level, then small technical layers inside each folder. The technical layers are still there — they're just no longer the first thing you see.

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 (a Money value object, a logger, a database connection). The key rule: if something is added to shared/, it should be extracted and owned deliberately — not dumped there as an escape hatch.
Naming the shared folder

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 common mistake

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?

By-layer: a new feature touches all four layer boxes. By-feature: a new feature touches only one box. BY LAYER — adding "refunds" controllers/ ← touched services/ ← touched repositories/ ← touched models/ ← touched 4 folders touched high blast radius BY FEATURE — adding "refunds" orders/ billing/ ← touched auth/ shared/ 1 folder touched contained blast radius
Adding a "refunds" feature. On the left (by-layer), all four technical layers light up — every layer folder is touched. On the right (by-feature), only 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.