When beginners start learning backend development, they usually pick up things one at a time and separately, Express routes here, controllers there, middleware somewhere else, then databases, then Prisma, then authentication. Each piece makes sense on its own.
But one question tends to linger even after all that. When a user clicks something on a website, what's actually happening behind the scenes, step by step?
Say someone opens an e-commerce site and clicks "Show my orders." What happens right after that click? Does Express talk to the database directly? Does Prisma just handle everything on its own? Where exactly does authentication slot in, and where does the actual SQL query get executed? Once this full flow actually clicks, backend development stops feeling like a pile of disconnected pieces.
The big picture
A modern backend app generally looks something like this.
Frontend Application
|
v
HTTP Request
|
v
Express Server
|
v
Routes
|
v
Controllers
|
v
Services
|
v
Database Layer
|
v
PostgreSQL Database
Each layer here has its own job, so let's walk through them one at a time.
The user sends a request. Say someone clicks "View Profile." The frontend fires off an HTTP request, something like GET /api/profile, carrying a method, a URL, maybe an authorization header, and sometimes a body. The browser genuinely has no idea your database even exists, it only ever talks to your backend API.
The request reaches the Express server, which is just sitting there running somewhere, waiting.
import express from "express";
const app = express();
app.listen(3000, () => {
console.log("Server running");
});
The moment that GET /api/profile request arrives, Express picks it up.
The router figures out where it goes. A real app has dozens of endpoints, /api/users, /api/products, /api/orders, /api/payments, and the router's whole job is deciding which piece of code handles which one.
router.get("/profile", getProfile);
In plain terms, if someone hits GET /profile, run the getProfile function.
Middleware runs before the controller ever sees the request. Think of it as a checkpoint the request has to pass through first.
Request
↓
Authentication Middleware
↓
Validation Middleware
↓
Controller
Say a request comes in with Authorization: Bearer token123. The middleware's job is simply asking, is this token actually valid?
function authMiddleware(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({
message: "Unauthorized"
});
}
next();
}
If everything checks out, next() runs and the request keeps moving forward.
The controller takes over.
async function getProfile(req, res) {
const userId = req.user.id;
const profile = await userService.getProfile(userId);
res.json(profile);
}
A controller's real job is just taking the request, pulling out what it needs, calling whatever service handles the actual work, and sending back a response. It really shouldn't be carrying heavy database logic itself.
The service layer handles the actual business logic.
async function getProfile(userId) {
const user = await prisma.user.findUnique({
where: { id: userId }
});
return user;
}
This is where the real database call happens.
Prisma steps in here. When your code calls prisma.user.findUnique(), Prisma quietly builds the actual SQL behind it, something like SELECT * FROM users WHERE id = 1;, and PostgreSQL runs it.
Service
↓
Prisma
↓
SQL Query
↓
PostgreSQL
↓
Result
The database sends data back, something like { "id": 1, "name": "Rahul", "email": "rahul@test.com" }, and it travels all the way back up through Prisma, the service, the controller, the Express response, and finally lands back at the frontend.
Put the whole thing together and you get this complete picture, from the click all the way to the UI updating.
User Clicks Button
|
Frontend Sends HTTP Request
|
Express Server
|
Router
|
Middleware
|
Controller
|
Service
|
Prisma / Drizzle / SQL
|
PostgreSQL
|
Response Returns
|
Frontend Updates UI
A question that comes up a lot at this point is, why not just call the database straight from the controller? Technically, you absolutely can.
app.get("/users", async (req, res) => {
const users = await prisma.user.findMany();
res.json(users);
});
That'll work fine for a small project. The trouble shows up once the app grows, controllers stacked directly on the database without any layers in between tend to balloon in size, logic gets duplicated all over the place, testing becomes a pain, and maintaining any of it gets genuinely difficult over time. With proper layering, routes to controllers to services to a dedicated database layer, code stays organized, testing gets easier, teams can actually work in parallel without stepping on each other, and future changes stay contained instead of rippling everywhere.
Where does all this code actually live
Knowing the flow is one thing, but figuring out where this code should actually sit inside a real project is the next question worth answering.
Beginners typically start with something dead simple.
src
├── index.ts
├── routes.ts
└── database.ts
That's genuinely fine for a small project. But as users pile up, products get added, payments show up, authentication gets bolted on, keeping everything crammed into a couple of files stops being manageable.
A more production style layout tends to look like this.
src
├── index.ts
├── app.ts
├── config
│ └── env.ts
├── routes
│ ├── user.routes.ts
│ └── product.routes.ts
├── controllers
│ ├── user.controller.ts
│ └── product.controller.ts
├── services
│ ├── user.service.ts
│ └── product.service.ts
├── repositories
│ ├── user.repository.ts
│ └── product.repository.ts
├── db
│ └── prisma.ts
├── middleware
│ ├── auth.ts
│ └── error.ts
├── validators
└── utils
Every folder here is pulling its own weight. index.ts is really just the entry point, its only job is starting the server, nothing more.
import app from "./app";
app.listen(3000, () => {
console.log("Server running");
});
app.ts is where the Express app actually gets configured, middleware loaded, routes wired up.
import express from "express";
import userRoutes from "./routes/user.routes";
const app = express();
app.use(express.json());
app.use("/api/users", userRoutes);
export default app;
The routes layer's whole job is deciding which URL goes to which controller, nothing about the database belongs here.
router.get("/profile", getProfile);
The controller layer handles request and response, pulling data out and handing it off.
export async function getProfile(req, res) {
const user = await userService.getProfile(req.user.id);
res.json(user);
}
The service layer is where the actual business logic lives, deciding what needs to happen, in what order, and under what rules.
export async function getProfile(userId: number) {
const user = await userRepository.findById(userId);
return user;
}
The repository layer is what talks directly to the database.
export async function findById(id: number) {
return prisma.user.findUnique({
where: { id }
});
}
Here's the real payoff of splitting things this way. If Prisma ever got swapped out for Drizzle down the line, only the repository layer would need to change, the service code sitting above it wouldn't need to know or care. That's really the whole point of the repository pattern.
And the database layer itself is usually just one shared client instance.
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
It's worth flagging why this matters, because beginners sometimes create a fresh Prisma client inside every single request.
app.get("/users", async (req, res) => {
const prisma = new PrismaClient();
});
That's a genuinely bad habit, since it can spin up way too many database connections and hurt performance badly. Sticking to a single shared instance, sitting on top of a proper connection pool, is the better approach.
Application
↓
Single Prisma Client
↓
Database Connection Pool
↓
PostgreSQL
A login flow is a good example of how all these layers cooperate. A POST /login request moves through the route, into the controller, into an auth service, which hits the database, checks the password, generates a JWT, and finally sends back a response.
const user = await userService.findByEmail(email);
const valid = await bcrypt.compare(password, user.password);
const token = jwt.sign({ id: user.id });
User input should never head straight to the database untouched either. Something like { "name": "", "email": "wrong-email" } needs to pass through a validation layer first, usually something like Zod, Joi, or Yup.
const schema = z.object({
email: z.string().email(),
});
Errors deserve real handling too, not just a stray console.log(error) buried in a try-catch. A cleaner approach routes errors through dedicated middleware instead.
app.use((error, req, res, next) => {
res.status(500).json({
message: "Something went wrong"
});
});
Put together, the full architecture looks like this.
Client
↓
Routes
↓
Controllers
↓
Services
↓
Repositories
↓
Prisma / Drizzle
↓
PostgreSQL
None of this is mandatory for every project though. A small app can genuinely get by with just routes, controllers, a db folder, and an entry file. Services and repositories are things you layer in as the project actually grows into needing them, not something you're required to bolt on from day one. The real goal here was never piling on more folders for their own sake, it's making sure every piece has a clear, single responsibility. If the database ever needs to change, the whole app shouldn't need rewriting. If auth logic changes, only auth related code should need touching. That's really what a maintainable backend comes down to.
What's actually happening between Express, the ORM, and the database
There's still one layer worth pulling apart properly, what's actually going on inside that database layer. When you write await prisma.user.findMany() or await db.select().from(users), what actually reaches the database? Does Prisma talk to PostgreSQL directly? How does a connection even get established? And what happens when a hundred people hit your API at the same exact moment?
The flow isn't quite as direct as beginners often assume. It's not just Express straight to database, it's really more like this.
Express Application
↓
Database Client / ORM
↓
Database Driver
↓
Database Server
Or more concretely.
Express
↓
Prisma Client
↓
PostgreSQL Driver
↓
PostgreSQL
A database driver is really just the software layer handling the actual back and forth between your app and the database. Connecting Node.js to PostgreSQL usually starts with installing the pg package.
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
That driver's job is opening connections, sending queries, and handing results back. Prisma doesn't reinvent PostgreSQL's wire protocol itself, it sits on top of a driver underneath it.
Your Code
↓
Prisma
↓
Database Driver
↓
PostgreSQL
So await prisma.user.findMany() becomes SELECT * FROM users; under the hood, and the driver is what actually carries that query over to the database.
When your app first starts up, the database client gets initialized, though the actual connection usually isn't opened right away, it tends to get established only when it's actually needed, sometimes called a lazy connection.
Server Start
↓
Database Client Initialize
↓
Connection Available
↓
Requests Accept
This is where connection pooling becomes a genuinely important concept. Picture a hundred users hitting your API at once. If every single request tried spinning up a brand new database connection from scratch, the database would get overwhelmed pretty fast. That's exactly what connection pools exist to prevent, a small set of ready-to-go connections sitting in a pool.
Application
↓
Connection Pool
[Connection 1] [Connection 2] [Connection 3]
↓
PostgreSQL
A request comes in, grabs whatever connection's free, does its work, and hands that connection back to the pool once it's done. Say your pool caps out at 10 connections and 100 users show up at once, the first 10 requests grab connections right away, and everyone else just waits their turn until one frees up.
Development and production databases also tend to look pretty different day to day. Locally you're often just running something like PostgreSQL in a Docker container.
docker run postgres
In production, you're usually looking at a managed PostgreSQL service instead, something like AWS RDS, Neon, Supabase's PostgreSQL offering, or Railway.
And database credentials should never be hardcoded directly into your code.
// don't do this
const db = "postgres://user:password@localhost";
That's both a security risk and a deployment headache waiting to happen. Environment variables are the better home for this.
DATABASE_URL=postgresql://user:password@host/database
const url = process.env.DATABASE_URL;
Tracing a real request end to end makes all of this click. Say a GET /api/users/10 request comes in. Express routes it through router.get("/users/:id", getUser). The controller pulls the id out of the params and calls the service. The service calls the repository. The repository calls prisma.user.findUnique({ where: { id } }), which Prisma turns into SELECT * FROM users WHERE id=10;. PostgreSQL runs that, finds the row, and hands it back. From there it travels back up through Prisma, the repository, the service, the controller, and out through Express to the browser as something like { "id": 10, "name": "Rahul", "email": "rahul@test.com" }.
Validation and authentication both slot in before the database ever gets touched. Registration data gets checked by something like Zod before it's allowed anywhere near the database. Protected routes run through JWT middleware first, and if that token's invalid, the database query simply never executes at all.
None of this is just academic trivia either. If you only ever memorize Prisma syntax or Express routing without understanding this flow, you can absolutely build things that work. But the moment something actually breaks, a slow query, a failing database connection, memory creeping up, a sluggish API response, that's exactly when understanding this architecture actually pays off. Backend development was never really about memorizing syntax. It's about understanding the journey a request takes from start to finish.
Pulling it all together
Putting the entire journey in one place looks like this.
User Request
↓
Express Server
↓
Route
↓
Middleware
↓
Controller
↓
Service
↓
Repository
↓
ORM / Database Driver
↓
Database
↓
Response
Or laid out as a full production style diagram.
Client
|
v
HTTP Request
|
v
Express Server
|
v
Router
|
v
Middleware Layer
(Auth, Validation, Logging)
|
v
Controller
|
v
Service
(Business Logic)
|
v
Repository
(Database Logic)
|
v
Prisma / Drizzle / SQL
|
v
PostgreSQL
A lot of beginners end up wondering whether the API itself is basically the database. It's not, an API is really just a communication layer. The frontend asks for something, GET /api/products say, and the backend decides whether that user's allowed to see it, what data actually needs fetching, what query the database needs, and how the response should be shaped. The API's really just acting as the messenger in between.
And there's a good reason the frontend never talks to the database directly, even though some databases technically allow it. Exposing credentials directly to the frontend is a straightforward security risk, since anyone poking around the client code could grab them. Business rules also need somewhere to live, checking stock availability, verifying payment completion, making sure a user isn't banned, and none of that belongs sitting in the frontend. And directly exposing a production database to the open internet is just asking for trouble. The safer shape is always frontend to backend API to database, never frontend straight to database.
Mistakes that tend to show up along the way
Cramming everything into the controller is a really common one early on, validating users, checking products, calculating prices, updating the database, sending emails, building the response, all crammed into one route handler. It works at first, but that controller balloons into a thousand line mess pretty quickly on a bigger project. Splitting things into controller, service, and repository keeps that from happening.
Scattering database queries across multiple controllers is another one, prisma.user.findMany() showing up in one controller file and again somewhere else entirely. That spreads database logic all over the codebase instead of keeping it contained inside a proper repository layer.
Treating the ORM like it's pure magic is a subtler trap. prisma.user.findMany() looks effortless on the surface, but underneath it's still just running SELECT * FROM users;. If the underlying SQL never actually makes sense to you, diagnosing real performance issues later becomes genuinely hard.
Ignoring proper error handling is another common gap, and in production, things will go wrong eventually, a database going down, bad input, network hiccups, unauthorized access attempts, all of it needs a proper path through central error handling middleware rather than getting quietly swallowed.
And hardcoding secrets directly into code, things like const password = "123456", is a habit worth killing early. Environment variables exist specifically so things like DATABASE_URL, JWT_SECRET, and API_KEY never end up sitting in plain code.
Applications also change shape over time, a users table might start with just id, name, and email, then later pick up a phone number and a created_at timestamp. Migrations exist to track exactly these kinds of structural changes over time, tools like Prisma Migrate, Drizzle Kit, Flyway, or Liquibase are all essentially version control for your database's shape.
Good backends also tend to test layer by layer, controller tests checking whether the response looks right, service tests checking whether the business rules actually hold, repository tests checking whether the database queries themselves behave correctly. And logging matters a lot more once something's actually live, tools like Pino or Winston help capture things like request paths, status codes, and response times so debugging in production isn't a total guessing game. On top of that, keeping an eye on API response times, database performance, error rates, and general server health is just part of running something in production responsibly.
What a solid backend developer actually needs to understand
Realistically, it comes down to a handful of layers stacking on top of each other. HTTP basics, requests, responses, headers, status codes. Express itself, routes, middleware, controllers. The database, SQL, relationships, indexes, transactions. The ORM sitting on top of that, Prisma, Drizzle, migrations. The overall architecture tying it together, services, repositories, proper error handling. And finally, production concerns, deployment, logging, security.
A reasonable learning order tends to look like this: get comfortable with JavaScript or TypeScript fundamentals like async/await, promises, modules, and types. Then pick up HTTP basics, REST conventions, methods, status codes, headers. From there, build actual things with Express, simple APIs, middleware, basic auth. Then move into PostgreSQL properly, tables, relationships, joins, indexes. After that, bring in an ORM, Prisma first, then Drizzle. And finally, round it out with production concerns, Docker, deployment, logging, security.
Honestly, the easiest way to hold all of this in your head is to think of a backend like a restaurant. The frontend user is the customer. The API is the waiter. The business logic is the chef. The database is the kitchen. And the application's rules are basically the recipe everyone's following. A customer never walks straight into the kitchen themselves, there's a whole system standing between them and the food, and a backend works exactly the same way, frontend to API to business logic to database.
At the end of the day, the real skill in backend development was never memorizing one specific framework. Express, Prisma, Drizzle, PostgreSQL, these are all just tools. What actually matters is understanding how a request travels, how data gets stored, where business rules actually belong, how database queries get executed under the hood, and how to keep an architecture maintainable as it grows. Once those ideas are genuinely clear, picking up whatever new backend tool shows up next stops being intimidating, because at that point you're not just learning a tool, you're recognizing a system you already understand.
Top comments (0)