devcrea.com
DevOps & Systems

Node.js Project Structure: MVC, Layered, and Feature-Based Architectures

Pick the right Node.js folder structure for your team, avoid cyclic dependency crashes, and migrate from MVC to feature-based architecture without a rewrite.

Node.js Project Structure: MVC, Layered, and Feature-Based Architectures

Putting every route, controller, and database query in one index.js file might get you through a weekend hackathon. Once another developer joins, though, that file becomes hard to manage. If controllers grow past 2,000 lines and circular dependencies crash the app at startup, you need a structure that can grow with the project.

Architecture Best used for Key advantage Major drawback
MVC Prototypes, MVPs, solo projects Quick to set up Gets messy as features grow
Layered Medium apps, complex business logic Separates database access from business logic Services can become tightly coupled
Feature-based Large teams, enterprise apps, microservices Keeps domains separate and reduces merge conflicts More setup at the start

This article assumes you're using a recent Node.js release with TypeScript already in place.

The 3 Biggest Scaling Mistakes in Node.js Architectures

Before choosing where files should go, it helps to understand what happens when a Node.js backend outgrows its original structure. Teams often spot the problem only after everyday changes start taking much longer.

Circular Dependencies That Crash the App

This happens when UserService requires AuthService while AuthService imports UserService to check permissions. The modules depend on each other during initialization, and the app can throw confusing undefined is not a function errors at startup.

Controllers That Do Everything

It starts with a small addition: an email trigger in the user registration route. Then comes a Stripe payment hook. Before long, user.controller.ts has grown into a 3,000-line file that handles database calls, third-party APIs, and response formatting. Testing any one part becomes difficult.

Constant Merge Conflicts in Shared Folders

When five developers work on different features but all need to edit files in the same global services/ or controllers/ directory, merge conflicts become routine. Time goes into resolving overlapping imports instead of shipping features. Untangling the history may also mean using git revert or git reset more often than anyone wants.

Choosing a Folder Structure That Fits Your Project

An enterprise architecture can slow down a startup MVP, while a structure that's too simple can hold back a growing team. Choose based on the size and needs of the project you have now.

Advertisement

MVC (Model-View-Controller): For Prototypes and MVPs

MVC groups files by technical role. It's a practical choice for a simple CRUD app or a short-lived internal tool.

  • Models: Define database schemas and handle raw queries.
  • Controllers: Process HTTP requests and format JSON responses.
  • Routes: Connect URLs to controller functions.

The trouble starts when business rules get more complex. MVC has no dedicated place for logic that doesn't map neatly to a database table, so controllers tend to absorb it and take on too many responsibilities.

Layered Architecture: For Medium Apps with Complex Logic

A layered structure adds a services layer for business rules that don't belong in controllers.

  • Controllers: Validate incoming requests, such as checking an email address, and pass data to a service.
  • Services: Handle tasks such as checking whether a user exists, hashing a password, and sending a welcome email.
  • Data access / repositories: Manage direct database interactions.

This keeps controllers focused and works well with the endpoint conventions in REST API design best practices. But user, product, and payment services may still end up together in one large src/services folder. As the team grows, developers can start calling services across domains until those boundaries become hard to follow.

Feature-Based Architecture: For Large Teams and Future Microservices

Feature-based architecture groups files by business domain, such as users, billing, or inventory, rather than by technical role. Everything related to a domain lives together.

A developer working on billing can open src/features/billing/ and find its routes, services, models, and middleware in one place. Clear domain boundaries also make it easier to move a feature into its own microservice later, if the project needs that.

A Modern Feature-Based Structure with TypeScript

A feature-based structure works best when the boundaries between application infrastructure, shared code, and business domains are clear. Here's one way to organize a TypeScript Node.js backend.

Advertisement

Organizing Core, Domain, and Shared Code in /src

Your src/ directory should separate application-wide infrastructure from individual business features.

  • /src/core/: Holds application-wide infrastructure, including Express server setup, database connection pools, logging configuration (Winston or Pino), and environment variable validation.
  • /src/shared/: Holds utilities, custom error classes, and global middleware, such as JWT verification, that multiple features use.
  • /src/features/: Holds the business features. Each folder represents a distinct domain.

Set up a path alias in tsconfig.json so imports can point to these folders without long chains of ../../../:

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@core/*": ["core/*"],
      "@shared/*": ["shared/*"],
      "@features/*": ["features/*"]
    }
  }
}

Splitting Routes, Controllers, and Services

Within a feature folder such as src/features/orders/, keep a small layered structure of its own:

  • orders.routes.ts: Connects endpoints to controller methods.
  • orders.controller.ts: Reads query parameters and handles HTTP status codes.
  • orders.service.ts: Runs the order processing logic.
  • orders.schema.ts: Contains Zod or Joi schemas for validating incoming requests.
  • orders.interfaces.ts: Contains TypeScript types specific to orders.
// 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' });
  }
}

Keeping these files together makes the order creation flow easier to follow. Developers don't have to jump between several top-level directories to make one change. The same principle can help when deciding whether a helper should be a plain module or one of your static classes in TypeScript.

Refactoring Your Current Project: A Migration Path

You can move a large MVC project toward a feature-based structure one domain at a time while continuing to build features.

Moving from MVC to Feature-Based Folders

Start with the domain that's causing the most trouble, often users or orders. Create a new src/features/ folder and move only that domain's files into it.

Update the imports and check that the rest of the application still works. Once the feature is isolated, move to the next one. This gradual approach, often called the strangler fig pattern, lets you improve the structure without rewriting everything at once.

Advertisement

Putting TypeScript Types and Interfaces in the Right Place

During a migration, it's tempting to put every TypeScript interface in one global src/types/ folder. That makes feature boundaries less useful.

Keep types that belong to a domain inside its feature folder. For example, an interface representing a user database row belongs in src/features/users/user.interfaces.ts. Use the global src/types/ folder for extensions to external libraries, such as adding a custom user payload to the Express Request object.

Choose a structure that fits your project today. A solo MVP may move faster with MVC. A five-person team dealing with regular merge conflicts may be ready for feature-based folders.

Frequently asked

Questions & answers

What's the best folder structure for a Node.js project?

For a solo project or MVP, a simple MVC structure usually works well. For a medium-sized app with substantial business logic, add a services layer. For a large team or an app that may split into microservices, organize feature folders by domain, such as users, billing, and orders.

Should I use MVC for a Node.js backend?

MVC works well for prototypes, small internal tools, and short-lived projects. It groups files by technical role, but it doesn't provide a dedicated place for business logic. Once your app grows beyond simple CRUD, controllers can become hard to manage.

How should I structure a large Node.js application?

For a large app, group routes, controllers, services, and types by business domain in src/features/. Keep application-wide infrastructure in src/core/ and shared utilities in src/shared/. This makes each domain easier to work on separately and reduces merge conflicts.

What is feature-based architecture in Node.js?

Feature-based architecture groups files by what they do, such as users, orders, or billing, instead of by technical role, such as controllers, services, or models. Each feature has its own folder, which can make it easier to extract into a separate microservice later.

When should I refactor my Node.js project structure?

Consider refactoring if circular dependencies crash the app at startup, a controller grows past a thousand lines, or developers regularly hit merge conflicts in shared folders. Move one domain at a time using the strangler fig pattern instead of rewriting the whole app at once.

Share Article