At the start, structure doesn’t matter. You just want the thing to run. One file, one route, ship it.

Once you have multiple features, multiple developers, real users, your folders start turning into chaos. That’s usually the exact moment you have the least time to fix it properly.

The Classic Layered Structure

Every tutorial teaches this one first, group by type:

project/
controllers/
services/
models/
repositories/

Predictable for small projects. Need a service, go to services/. Nothing to think about, the folder name tells you where to look.

But work on one feature, say tickets, and you’re jumping across four folders for one change:

controllers/ticketController.ts → services/ticketService.ts
→ repositories/ticketRepository.ts → models/ticket.ts

Feature-First Structure

Group by feature instead of type:

project/
features/
auth/
auth.controller.ts
auth.service.ts
auth.model.ts
tickets/
ticket.controller.ts
ticket.service.ts
ticket.model.ts
shared/
database/
middleware/

Working on tickets? Everything’s in features/tickets/. No jumping, no remembering four file locations. With multiple developers, one owns auth/, another owns tickets/, fewer merge conflicts almost automatically.

You also start thinking in features instead of technical layers, matching how product work actually gets scoped. Nobody says “let’s ship a new service layer.” They say “let’s ship the tickets feature.”

The tradeoff: “shared” becomes a judgment call. I keep shared/ for things that are genuinely infrastructure, database clients, auth middleware, logging, not domain logic that happens to be reused.

What I Actually Do

Solo project or something small, plain layered. Fast, no overthinking, no premature structure for a codebase that might not exist in a month:

app/
models/
services/
routes/

Team project, or anything past a handful of features, feature-first, with a core/ folder for shared stuff:

src/
features/
tickets/
models/
services/
routes/
core/
database/
auth/

The Migration Path Nobody Talks About

Going from layered to feature-first on a live codebase isn’t a find-and-replace job. The real work is untangling implicit coupling the layered structure hid from you. A ticketService.ts sitting next to authService.ts looks like a peer, move it and you notice it quietly imports from authService for no conceptual reason.

That’s the real value of the migration, not the rename but what it forces you to notice. Every import crossing a new feature boundary is a coupling you couldn’t see before. Some belong in core/, some are accidental, and the migration is where you find out which.

The Honest Reality

There’s no “perfect” structure, anyone claiming otherwise is usually selling something. It depends on team size, complexity, and how much you care that particular day. A CRUD app for a class project doesn’t need enterprise architecture.

Small project, keep it simple. Team project, feature-first, almost every time. No need to overthink it further.

Folder structure alone won’t save bad code. But it decides how fast your future self can find the thing they’re looking for when something breaks. Even if that future reader is just you, three months from now.

Conversation

Discussion

Comments will appear here after GitHub Discussions and Giscus are configured for this repository.