Node.js doesn't tell you how to organize anything. That's either its best quality or the source of your current problem.
Rails gives you conventions. Django gives you conventions. Laravel gives you conventions. Node.js gives you a module system and says good luck. In month one this feels like freedom. In month eighteen, when six developers have each been quietly doing things their own way, it feels like archaeology.
I've worked in Node.js codebases that were genuinely miserable to change. Not because the people who built them were bad engineers - they weren't. Because nobody made explicit structural decisions early and the codebase became a record of whatever each developer defaulted to when there was no convention to follow. I've also worked in codebases that stayed clean through significant growth and team changes. The difference was almost always deliberate early decisions, not talent.
Here's what the maintainable ones do.
The Business Logic in Route Handlers Problem
This is where most Node.js projects start accumulating structural debt.
Route handlers are convenient. The request comes in, you have the data, you do the thing, you return the response. Why add another layer? Just write the logic here.
The problem shows up the first time you need to trigger the same business logic from somewhere other than an HTTP request. A queue consumer. A scheduled job. A CLI command. Suddenly you're either duplicating the logic or you're importing a route handler from a non-HTTP context, which is wrong in ways that are hard to explain to someone who hasn't been burned by it.
Business logic belongs in services. Not because of some architectural purity principle - because route handlers change when API contracts change, and business logic changes when business requirements change, and those are different reasons that happen at different times. When both live in the same function, every change requires understanding both layers simultaneously.
Route handlers should be thin. Receive request, extract parameters, call a service, return a response. That's it. The moment a route handler starts making decisions about business rules, you've coupled two things that should be separate.
Stop Reading process.env Everywhere
Walk through a typical Node.js codebase and count how many files read directly from process.env. Database files reading process.env.DATABASE_URL. Email files reading process.env.SMTP_HOST. Auth files reading process.env.JWT_SECRET. Configuration scattered across thirty files with no single place to understand what the application actually requires to run.
This creates two problems that seem minor and aren't.
First, you have no startup validation. The application starts, accepts traffic, and then fails the first time a request hits a code path that needs a missing environment variable. The error is cryptic. The failure is deep in the stack. The missing variable could have been caught immediately if anyone had checked at startup.
Second, you have no complete picture of the application's configuration requirements. New developers setting up locally have to discover required variables by running the application and seeing what breaks. This is a terrible onboarding experience that scales with codebase size.
One configuration module. It reads all environment variables. It validates them at startup - throws immediately with a clear message if anything required is missing. It exports a typed configuration object. Everything else in the application imports from that module.
Simple. Boring. Consistently absent from codebases that are painful to operate.
Feature Folders Beat Layer Folders at Scale
Layer-based structure - controllers, services, models, middleware, routes all in separate top-level folders - is the default that most Node.js projects start with. It feels organized. Everything of the same type lives together.
The problem is that features don't live in one type. A user registration feature touches the controller, the service, the model, the validator, and the routes. When those five files live in five different folders, working on that feature means navigating between five places. Every feature change is a multi-folder exercise. The connection between these files is implied by naming rather than expressed by structure.
Feature-based organization keeps the user controller, user service, user model, user validators, and user routes in a single users folder. Working on user registration means working in one place. The relationship is expressed by proximity rather than naming convention.
This doesn't mean there are no shared folders. Middleware, utilities, configuration, database setup - things that are genuinely shared across features need a home. Usually a shared or common folder. The discipline is keeping that folder from becoming a dumping ground for everything that doesn't obviously belong somewhere else. When everything is "shared," nothing is organized.
Repository Pattern Is Not Overengineering
I've heard the argument against it many times. "We're already using an ORM. Adding a repository layer is just more abstraction for no reason."
The argument misunderstands what the problem is.
The ORM is an abstraction over the database driver. It is not an abstraction over your data access layer. When service functions call the ORM directly, the ORM's query API is now visible throughout your business logic. Your service functions know about database columns, query syntax, relationship loading strategies. When the schema changes, changes ripple through service functions. When you want to test service logic, you need to either use a real database or build elaborate ORM mocks.
A repository puts a thin named interface between services and data access. userRepository.findByEmail(email). The service doesn't know whether that hits Postgres, Redis, or an external API. The repository knows. The service doesn't need to.
The testing difference is the real argument. A service that depends on a repository interface can be tested with a simple in-memory implementation - a plain object with the same method names that returns test data. Fast tests, no database, no external dependencies. A service that calls Prisma directly needs either a real database or mocking internals that change between Prisma versions. The first approach is stable. The second is maintenance work disguised as testing.
Error Handling That Doesn't Accumulate Inconsistency
Here's what happens without a deliberate error handling strategy.
Developer A uses try-catch everywhere and returns error objects. Developer B throws strings. Developer C throws Error instances with no typing. Developer D uses a custom error class they invented. Six months in, reasoning about how errors flow through the application requires reading every function rather than understanding a convention.
The strategy that scales: business logic throws typed custom errors. ValidationError. NotFoundError. AuthorizationError. These extend a base AppError class that carries a status code and a user-facing message. Services throw them when something goes wrong. Route handlers don't catch them - they propagate up to a centralized error handling middleware that maps error types to HTTP responses.
One place. All the error-to-status-code mapping. Adding a new error type means adding it to the custom error classes and adding a handler in the central middleware. It does not mean finding every route handler that might encounter the new error and updating each one.
The unhandledRejection and uncaughtException process handlers get set up once. They log everything useful - full error, stack trace, context - and then shut down gracefully. Not silent swallowing. Not crashing without logs. Log and exit so the process manager can restart cleanly.
Tests That People Actually Write
Test coverage in Node.js projects decays when writing tests is hard.
This sounds obvious. The implication is less obvious: whether people write tests is largely a function of how the code is structured, not how motivated the team is. Services decoupled from HTTP and database layers are genuinely easy to unit test. Pass inputs, assert outputs, mock the repository with five lines of code. Route handlers that are thin wrappers around services are easy to integration test at the HTTP level. The layers test independently.
When business logic lives in route handlers, testing requires understanding the HTTP layer and the business rules simultaneously. The test setup is more complex. The test breaks when either layer changes. People stop writing them.
The testing strategy that holds up: unit tests for service logic, integration tests for API endpoints using a test database, and nothing in between. No unit tests for route handlers that only delegate to services - thin route handlers don't have logic worth unit testing. No integration tests that try to test business rules through HTTP - that's what service unit tests are for.
The coverage holds when the structure makes writing tests easier than not writing them.
What This Looks Like in Hiring
Structure is one of the most useful things to talk through with Node.js candidates and one of the most skipped.
Ask them to describe a Node.js project they worked on and how it was organized. What would they do differently now? Ask whether they've worked in a badly structured codebase - what made it hard, what did they change. Ask how they decide where business logic should live.
Developers who have lived with the consequences of poor structure have real opinions. They've felt route handlers that were impossible to test. They've felt configuration scattered across thirty files. They've felt the moment a feature change required touching seven folders. These experiences produce specific answers.
Developers who haven't tend to describe what they built rather than how it was organized.
When you hire nodejs developers who will own a production codebase over time, structural judgment is what determines whether the codebase is easier or harder to work in a year from now. Features get built regardless. The question is whether they accumulate into something coherent or something archaeological.
Hyperlink InfoSystem screens Node.js developers specifically for this - not just technical ability but the production experience that produces structural instincts. The codebase your team lives in for the next three years is shaped by the decisions made in the first three months. Getting the right engineers in early is when it matters most.
Top comments (0)