Many Express projects become messy not because the framework is weak, but because the team delays structure until the codebase is already noisy.
Early folder decisions influence how quickly you can debug routes, add features, and onboard someone new.
Beginners should keep the structure small but intentional. Professionals should keep it predictable and ownership-friendly.
A good setup is one that makes responsibility visible instead of hiding it behind random file placement.
One of the most common beginner mistakes is copying a very large folder structure before there is enough code to justify it. That usually creates confusion because half the folders are empty and the reasons behind them are unclear.
A better start is a small structure with obvious responsibilities: routes, controllers, services, middleware, and config. When the app grows, you can refine it instead of pretending to have a big architecture on day one.
A weak structure usually shows up as duplicated validation, large route files, random helper modules, and no clear place for business rules. The backend may still run, but every change becomes slower.
Professionals recognize that file structure affects team speed. When nobody knows where new code should live, every feature starts by searching instead of building.
Start with a small application entry point that creates Express, registers shared middleware, mounts feature routers, and adds not-found and error handlers. Keep server startup separate from app creation so integration tests can import the application without opening a real network port.
Organize business features such as users, orders, and authentication into their own folders. Each feature can contain its router, controller, validation schema, service, and repository. This keeps related code close together and prevents one global controllers or services folder from becoming an unsearchable collection as the project grows.
Load configuration once, validate required environment values at startup, and expose a typed configuration object. Create shared infrastructure modules for logging, database access, errors, and observability. Avoid importing process.env throughout the codebase because missing or malformed values then fail unpredictably during requests.
As an Express service becomes serious, folders should reflect the request path and the domain path. Requests arrive through routes and controllers, but business rules and data access should not remain stuck in those outer layers forever.
The goal is not perfect architecture terminology. The goal is to create a codebase where the entry point is clear, the business decision point is clear, and the infrastructure connection point is clear.
Implement a create-order feature with route, controller, service, repository, and validation modules. Keep HTTP objects out of the service so business behavior can be tested without starting a server.
Work through this as a controlled engineering exercise rather than a copy-and-paste demo. State the expected result before running anything, keep the input small enough to inspect, and record the important intermediate state. That makes the lesson explain not only what to type, but why the result is trustworthy.
Circular imports and controllers that query the database directly erase the intended boundaries. A folder structure has no value when dependencies still point in every direction.
Verification must use evidence that matches the concept. Unit-test the service with a fake repository and integration-test only the HTTP boundary, then inspect imports for forbidden dependencies. Repeat the check after deliberately introducing the failure, then after the fix. The contrast between those runs is the part that turns a definition into practical understanding.
Use composition at the application edge. Construct repositories and gateways, inject them into services, and inject services into controllers or router factories. Core business modules should not import the Express app, global mutable containers, or database clients directly. This direction improves tests and makes runtime dependencies visible.
Define startup and shutdown behavior. Establish the database pool, register signal handlers, stop accepting traffic, close the HTTP server, drain bounded in-flight requests, stop consumers, and close pools. Startup should fail fast when required dependencies or migrations are unavailable rather than serving partially initialized traffic.
Add linting, formatting, static checks, unit tests, integration tests, migration commands, and a production start script. Keep generated output separate from source. Document the commands, environment variables, health endpoints, and local dependency setup so a new developer and CI execute the same workflow.
This is a practical middle ground between a toy app and an over-engineered starter.
src/
app.js
routes/
controllers/
services/
middleware/
config/
db/
utils/
Adapt this focused example to a disposable local environment and inspect every result before expanding it.
src/orders/order.routes.js
src/orders/order.controller.js
src/orders/order.service.js
src/orders/order.repository.js
src/orders/order.schema.js
Keep one business capability understandable without searching the whole repository.
src/app.js
src/server.js
src/config/index.js
src/platform/database.js
src/platform/logger.js
src/orders/order.routes.js
src/orders/order.controller.js
src/orders/order.service.js
src/orders/order.repository.js
src/orders/order.schema.js
tests/integration/orders.test.js
Tests import createApp while production owns the listening socket.
export function createApp(dependencies) {
const app = express();
app.use(express.json({ limit: \"100kb\" }));
app.use(\"/orders\", createOrderRouter(dependencies.orders));
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
const dependencies = await buildDependencies(config);
const server = createApp(dependencies).listen(config.port);
Not necessarily. Borrow useful separation ideas, but keep the structure practical for your project and team.
When business logic starts being reused, grows beyond simple request handling, or becomes hard to test inside controllers.
Explore 500+ free tutorials across 20+ languages and frameworks.