Backend Project Structure

The folder and file layout that keeps routes, business logic, and data access separated as a backend app grows.

What is it?

A backend with two or three routes can live comfortably in a single file. But add validation, error handling, a database, and a dozen more endpoints, and that one file turns into hundreds of lines mixing three very different concerns: parsing HTTP requests, deciding what should actually happen, and talking to the database.

Project structure is how you split those concerns into folders so each piece of code has one clear job and a predictable place to live — commonly routes/ (which URLs exist), controllers/ (translate a request into a plain function call and a response), services/ (the actual business logic), and models/ (talking to the database).

Explain like I'm 10

Think of a restaurant kitchen: the host at the door (routes) decides which table a guest goes to, the server (controller) takes the order and carries it back, the chef (service) actually cooks using the recipe, and the pantry (model) is where the raw ingredients live. Each role could technically be done by one overworked person, but splitting them is what lets a kitchen serve more than a handful of tables without chaos.

Examples

Before: everything in one file

// server.js — every concern tangled together
app.post("/users", async (req, res) => {
  if (!req.body.email || !req.body.email.includes("@")) {
    return res.status(400).json({ error: "invalid email" });
  }
  const existing = await db.query("SELECT * FROM users WHERE email = $1", [req.body.email]);
  if (existing.rows.length > 0) {
    return res.status(409).json({ error: "email taken" });
  }
  const result = await db.query(
    "INSERT INTO users (email) VALUES ($1) RETURNING *",
    [req.body.email]
  );
  res.status(201).json(result.rows[0]);
});

Validation, business rules, and raw SQL are all crammed into the route itself — fine for one endpoint, unmanageable once there are fifty.

After: split across layers

// routes/users.js
router.post("/users", usersController.create);

// controllers/usersController.js
async function create(req, res) {
  const user = await usersService.createUser(req.body.email);
  res.status(201).json(user);
}

// services/usersService.js
async function createUser(email) {
  if (!email.includes("@")) throw new ValidationError("invalid email");
  if (await usersModel.findByEmail(email)) throw new ConflictError("email taken");
  return usersModel.insert(email);
}

// models/usersModel.js
function findByEmail(email) {
  return db.query("SELECT * FROM users WHERE email = $1", [email]);
}

Each file now has one job: the route maps a URL to a controller, the controller only translates HTTP in and out, the service holds the actual rule ('emails must be unique'), and the model is the only place that knows any SQL.

The full folder tree, and how it actually gets run

my-api/
├── package.json          # scripts.dev = "node src/server.js"
├── src/
│   ├── server.js          # entry point: starts the app, calls app.listen()
│   ├── app.js             # creates the Express app, wires up middleware + routes
│   ├── routes/
│   │   └── users.js
│   ├── controllers/
│   │   └── usersController.js
│   ├── services/
│   │   └── usersService.js
│   ├── models/
│   │   └── usersModel.js
│   └── config/
│       └── db.js          # database connection setup
└── .env                   # DATABASE_URL, PORT, etc.

# Run it with:
$ npm run dev

server.js is the one file that actually gets executed — everything else (app.js and the folders beneath it) is just code that server.js, directly or indirectly, requires and calls. npm run dev is a shortcut defined in package.json for node src/server.js, so nobody has to remember the exact entry-file path by hand.

How it works

A request flows down through the layers and the response flows back up: the route matches a URL to a controller function, the controller pulls out what it needs from the request and calls a service, the service applies business rules and calls a model when it needs data, and the model is the only layer that actually knows how that data is stored. Each layer only talks to the one directly below it, which is what makes it possible to change one layer (like swapping databases) without rewriting the others.

Route  →  Controller  →  Service  →  Model  →  Database
(URL)     (HTTP in/out)  (business    (data
                          rules)       access)

Why does it exist?

Without any structure, HTTP concerns, business rules, and database queries end up tangled together in the same functions. That makes code hard to test (you can't test a business rule without also faking an entire HTTP request), hard to change (a database swap touches every route), and hard for a new developer to navigate (there's no predictable place to look for a given kind of logic).

When to use it

Reach for a layered structure as soon as a project has more than a handful of endpoints, or as soon as the same logic (like "is this email already taken") needs to be reused from more than one place.

When not to use it

For a tiny prototype, a quick script, or a project with two or three endpoints total, splitting into four folders is often pure overhead — one well-organized file is easier to follow than jumping between five nearly-empty ones. Add structure when the pain of not having it shows up, not before.

Common mistakes

  • Putting raw database queries directly inside route handlers or controllers, defeating the point of having a separate data-access layer.

  • Letting controllers grow their own business logic instead of delegating to a service, so the same rule gets duplicated across multiple controllers.

  • Introducing the full layered structure for a two-endpoint prototype, adding indirection before there's any real complexity to manage.

  • Not knowing which file is actually the entry point, and guessing at how to start the app instead of checking package.json's scripts.

Practice exercises

  1. Easy:

    For a function that checks whether a discount code has expired, name which layer (route, controller, service, or model) it belongs in and why.

  2. Medium:

    Take a route handler that both queries a database and formats a JSON response, and split it into a controller and a model function.

  3. Hard:

    Explain the trade-off between using a full layered structure for a 3-endpoint prototype versus keeping it in one file, and describe the signal that would tell you it's time to split it up.

Interview questions

Why split a backend project into routes, controllers, services, and models instead of one file?

To separate concerns so each layer has one job and a predictable location — making the code easier to test, change, and navigate as it grows.

What's the difference between a controller and a service?

A controller translates an HTTP request into a plain function call and shapes the response; a service holds the actual business logic, independent of HTTP.

How does a command like `npm run dev` know which file to actually run?

It's a shortcut defined in package.json's scripts section, pointing at the project's real entry file — the file that calls app.listen() and starts the server.

Which single layer should be the only one that knows actual SQL or database queries directly, and why does that matter?

The model layer — keeping database access confined to one layer means that if the storage technology or query details ever change, only that one layer needs updating, instead of hunting down scattered queries across routes, controllers, and services.

A controller function directly runs `db.query(...)` itself instead of calling a service. What problem does this create if the team later switches databases?

The database-specific code is now scattered across every controller that queries data directly, so switching databases means finding and rewriting all of them individually, instead of changing just the model layer that was supposed to be the only place that knew about storage details.

Why is it bad practice for the same business rule, like "email must be unique," to be duplicated inside multiple controllers?

If the rule ever changes or has a bug fix, every duplicated copy has to be found and updated consistently; missing even one creates inconsistent behavior between endpoints that are supposed to enforce the same rule.

What does it mean for each layer to "only talk to the one directly below it," and why does that make swapping a layer out easier?

It means a route only calls its controller, a controller only calls services, and services only call models — no layer reaches two levels down or skips ahead; this means replacing one layer (like changing databases at the model level) doesn't ripple upward, since nothing above it depends on its internal details, only on its interface.

What's the difference between `server.js` and `app.js` in the example project structure?

app.js creates the Express app and wires up middleware and routes, while server.js is the actual entry point that imports app.js and calls app.listen() to start it — separating app configuration from the act of starting the server makes the app itself easier to import and test without necessarily starting a live server.

A new developer isn't sure which file is a project's real entry point. Where should they look?

In package.json's scripts section, at whichever script actually runs the app (commonly dev or start) — that script names the real entry file directly, rather than requiring anyone to guess.

Why does testing a business rule get harder when that rule lives directly inside a route handler mixed with HTTP-specific code?

Testing it would require faking an entire HTTP request and response just to exercise a rule that has nothing to do with HTTP; extracting the rule into a plain service function lets it be tested directly with ordinary function calls and inputs.

A two-endpoint prototype adopts the full routes/controllers/services/models structure from day one. What's the likely downside?

For so little logic, the structure adds indirection — jumping between four or five nearly-empty files — without any real complexity yet to justify it, making the project harder to follow than a single well-organized file would be.

What signal tells a team it's time to move from a single-file backend to a layered structure?

When the same logic needs to be reused from more than one place, or the file has grown to the point that unrelated concerns (HTTP handling, business rules, database queries) are becoming difficult to tell apart or navigate.

Why does the route layer contain no logic in the layered example, and why keep it that thin?

Its one job is declaring which URL and method trigger which controller — keeping it thin means the mapping of endpoints to behavior stays easy to scan at a glance, without business logic cluttering the picture of what endpoints exist.

If `usersService.createUser` throws a `ValidationError` and the controller doesn't catch it, what happens to the request?

The error propagates up uncaught; depending on the framework's error-handling setup, this typically results in an unhandled rejection or a generic 500 error being sent, rather than the intended, specific error response — which is why controllers (or dedicated error-handling middleware) generally need to catch and translate expected errors from services.

What can a service function do that a model function specifically should not, and vice versa?

A service can apply business rules and decisions (like checking whether an email is already taken before creating a user), while a model should only handle the mechanics of reading and writing data; a model deciding business rules, or a service writing raw SQL, blurs the boundary the layers are meant to keep separate.

Why is `usersService.createUser` described as "reusable from anywhere, not tied to HTTP"? What does that buy you?

Because it takes plain arguments and returns plain results rather than reading from req or writing to res, it can be called from a route's controller, a background job, a CLI script, or a test — anywhere the same business rule is needed — without dragging along any HTTP-specific machinery.

What does the model layer being "the only file that knows how a user is actually stored" imply about switching from PostgreSQL to MongoDB, or adding caching?

It implies that change would be confined to the model layer's implementation — the services and controllers calling it wouldn't need to change at all, since they only rely on the model's function signatures (like findByEmail), not on how those functions are implemented internally.

A service throws an error because a requested resource doesn't exist. Whose job is it to turn that into an HTTP 404 — the service or the controller?

The controller's — the service shouldn't know or care about HTTP status codes since it's meant to be reusable outside an HTTP context; the controller is the layer responsible for translating a business-level outcome ("not found") into the appropriate HTTP response.

Why might a small internal tool or one-off script deliberately not adopt this layered structure at all?

With little or no reused logic and only a handful of operations, the overhead of maintaining separate route, controller, service, and model files outweighs any benefit — a single straightforward file is faster to write and easier to follow for something that small and short-lived.

What's the practical risk of not knowing your project's real entry point when debugging unexpected behavior?

You might edit or reason about a file that isn't actually being executed, or run the app in a way that skips configuration or middleware the real entry point sets up, leading to confusing results that don't match what you changed.