Dumping all your routes, controllers, and database logic into a single index.js file might work for a weekend hackathon, but it becomes an unmaintainable nightmare the second another developer joins the team. When your controllers swell past 2,000 lines and cyclic dependencies start crashing your application on startup, you need a structural framework designed for scaling, not just a folder template.
| Architecture | Best used for | Key advantage | Major drawback |
|---|---|---|---|
| MVC | Prototypes, MVPs, solo projects | Extremely fast to set up | Becomes a sprawling mess as features grow |
| Layered | Medium apps, heavy business logic | Clear separation of concerns (DB vs. logic) | Prone to tightly coupled services |
| Feature-based | Large teams, enterprise, microservices | Isolated domains, zero merge conflicts | Higher initial boilerplate |
Most guides assume you're on a recent Node.js release with TypeScript already in place, so that's the baseline this article works from too.
The 3 Biggest Scaling Mistakes in Node.js Architectures
Before deciding where to place your files, you need to understand exactly what breaks when a Node.js backend outgrows its initial boundaries. Most teams do not realize their structure is flawed until development velocity grinds to a halt.
Cyclic Dependencies Crashing the App
This happens when UserService requires AuthService, but AuthService simultaneously imports UserService to verify permissions. V8's execution order becomes unpredictable. Your app throws cryptic undefined is not a function errors right at boot.
The "God Object" Controllers
It starts innocently. You add an email trigger to the user registration route. Then a Stripe payment hook. Before long, your user.controller.ts is a 3,000-line monolith handling database calls, third-party APIs, and data formatting. Testing this file becomes virtually impossible.
Merge Conflict Hell in Shared Folders
If five developers are working on five different features, but everyone has to modify files inside a single global services/ or controllers/ directory, Git conflicts become a daily routine. You lose hours resolving overlapping imports instead of shipping features, and untangling the history often means reaching for git revert or git reset more often than anyone would like.
Choosing the Right Folder Structure for Your Project Scale
You cannot copy-paste an enterprise-grade architecture into a startup MVP and expect good results. Over-engineering on day one is just as fatal as under-engineering.
MVC (Model-View-Controller): For Rapid Prototypes and MVPs
MVC is the default for a reason. It groups files by their technical role. If you are building a simple CRUD application or an internal tool with a lifespan of a few months, this is your go-to.
- Models: Handle database schemas and raw queries.
- Controllers: Process incoming HTTP requests and format the JSON response.
- Routes: Map URLs to the correct controller functions.
The problem? MVC completely ignores business logic. As soon as you need complex operations that do not map 1:1 with a database table, you end up stuffing that logic into controllers, violating the Single Responsibility Principle.
Layered Architecture: For Medium-Scale and Logic-Heavy Projects
When MVC breaks down, the layered architecture steps in by introducing the services layer. This is where your actual business rules live.
- Controllers: Only validate the incoming request (checking if the email is valid, for example) and pass data to the service.
- Services: Handle the heavy lifting, like checking if the user exists, hashing the password, triggering a welcome email.
- Data access / repositories: Manage direct database interactions.
This keeps controllers lean, and it pairs well with the endpoint conventions covered in REST API design best practices. But all user services, product services, and payment services still live in one giant src/services folder. As the team grows, domain boundaries blur, and developers start calling services from other services, creating a tangled web.
Feature-Based Architecture: For Large Teams and Microservice Readiness
Instead of grouping files by what they are (controllers, services), feature-based architecture groups them by what they do (users, billing, inventory). Everything related to a specific domain lives in one isolated folder.
If a developer needs to work on the billing system, they open src/features/billing/. Everything they need, routes, services, models, and specific middleware, is right there. This structural isolation forces developers to think in bounded contexts, making it incredibly easy to extract a feature into a standalone microservice later.
Modern Feature-Based Structure with TypeScript
Building a robust feature-based structure requires strict boundaries. Here's how a modern, production-ready TypeScript Node.js backend should be organized.
Organizing Core, Domain, and Shared in /src
Your src/ directory should clearly separate global infrastructure from specific business features.
/src/core/: Houses application-wide infrastructure. This includes your Express server setup, database connection pools, logging configurations (Winston or Pino), and environment variable validations./src/shared/: Contains utility functions, custom error classes, and global middleware (like JWT verification) used across multiple features./src/features/: The heart of your application. Each folder inside here represents a distinct business domain.
Set up a path alias in tsconfig.json so imports point at these folders instead of chaining ../../../ across the tree:
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@core/*": ["core/*"],
"@shared/*": ["shared/*"],
"@features/*": ["features/*"]
}
}
}
Splitting Routes, Controllers, and Services
Inside a specific feature folder, like src/features/orders/, you maintain a miniature, self-contained layered architecture:
orders.routes.ts: Binds endpoints to controller methods.orders.controller.ts: Extracts query parameters and handles HTTP status codes.orders.service.ts: Executes the actual order processing logic.orders.schema.ts: Zod or Joi validation schemas for inbound request payloads.orders.interfaces.ts: TypeScript types specific to the order domain.
// orders.service.ts
import { OrdersRepository } from './orders.repository';
import { Order } from './orders.interfaces';
export class OrdersService {
constructor(private repo: OrdersRepository) {}
async createOrder(userId: string, items: Order['items']): Promise<Order> {
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return this.repo.insert({ userId, items, total, status: 'pending' });
}
}
By colocating these files, you eliminate context switching. A developer modifying the order creation flow does not have to jump across five different root-level directories. The same colocation logic applies to static classes in TypeScript when you're deciding whether a helper belongs as a class or a plain module.
Refactoring Your Current Project: The Migration Path
You do not have to halt feature development for a month to rewrite your entire architecture. You can migrate a bloated MVC project to a feature-based structure incrementally.
Painless Transition from MVC to Feature-Based
Start by identifying the most complex, pain-inducing domain in your current app, often the user or order module. Create a new src/features/ folder and move only the files related to that specific domain into it.
Update the imports. Ensure that the rest of the application still functions correctly. Once that single feature is isolated, move on to the next one. This strangler fig pattern lets you modernize your architecture piece by piece while continuing to ship updates.
Positioning TypeScript Types and Interfaces Correctly
One of the biggest mistakes teams make during a migration is dumping every single TypeScript interface into a global src/types/ folder. This defeats the purpose of modularity.
Keep domain-specific types strictly inside their respective feature folders. If an interface represents a database row for a user, it belongs in src/features/users/user.interfaces.ts. Reserve the global src/types/ folder exclusively for extending external libraries, like augmenting the Express Request object with custom user payloads.
Pick the structure that matches where your project actually is today, not where you hope it will be in a year. A solo MVP forced into a feature-based structure just adds friction; a five-person team stuck in MVC is already paying for it in merge conflicts.
