Skip to main content

Command Palette

Search for a command to run...

Building a Production-Grade Express.js Backend — Architecture, Structure & Business Logic

Updated
15 min read
S
Hi, I'm Shreshtha. I'm passionate about technology, problem-solving, and understanding how things work under the hood. Currently exploring JavaScript, Node.js, and backend development while documenting what I learn along the way. I love understanding how technology works behind the scenes and turning that learning into simple, practical explanations. I enjoy breaking down complex concepts into simple explanations and sharing insights from my learning journey. Through these blog, I write about programming, backend development, and ideas around technology and innovation. Always learning, always building.

Before We Write Code, We Think

Most tutorials teach you how to write code. This article teaches you how to think before writing it — and that gap is what separates someone who builds a feature from someone who builds a system.

What you're about to read is the foundation of a real Node.js and Express.js backend. It covers an authentication module and an e-commerce system. But more than showing what was built, it explains why the structure looks the way it does — the reasoning behind every folder, every file name, and every division of responsibility.

By the end of this section, you will be able to look at any feature request, break it into logical steps, and know exactly which file each step belongs in — before typing a single line.


The Architecture Diagram — Where It All Begins

Before a single file was created, a diagram was drawn. Not a database schema. Not a class hierarchy. Just three simple questions:

What does this application need to do? Who does what? What is shared, and what belongs to one feature?

The diagram revealed a clean request lifecycle: a User fires a fetch() call with name, email, and password. That request enters an Auth module, passes through a data validation check, reaches a controller, descends into a service layer, and finally talks to MongoDB through Mongoose. Every file in this project exists to serve one identifiable responsibility in that journey.

This is the principle of Separation of Concerns — the idea that no single file should do everything. The moment a file starts knowing too much, changing one thing breaks three others. The diagram is not decoration. It is the specification that produces the folder tree.

At the infrastructure level, the picture is even simpler. A Server runs Express. Express connects through Mongoose to a MongoDB database. req, res, and error flow through it. That's the skeleton. Everything else — auth, cart, products, orders — is flesh on those bones.


The Two Worlds Inside Every Backend

Before examining individual files, anchor this mental split. Every file in this codebase lives in exactly one of two worlds:

World 1 — The Plumbing. This is everything that keeps the application running regardless of what the application does. The server boot, the Express configuration, the database connection, shared utilities, global error handling. It knows nothing about users or carts or auth. If you deleted every feature module tomorrow, this layer would still compile.

World 2 — The Features. This is everything that implements a specific business function. The auth module. The cart module. The product module. These know everything about their domain and nothing about how the server boots.

In code, this translates directly into two folders: src/common/ for the plumbing and src/modules/ for the features. Nothing in modules/ is allowed to reach into a sibling module. The cart module does not import from the auth module. They both import from common/. This directionality is intentional — it prevents features from becoming entangled with each other.


The Complete File Structure — And Why Each File Exists

The Root Level

At the project root you will find server.js, app.js, .env, package.json, and .gitignore. These exist before your application thinks about users or products.

server.js is the entry point — the only file Node.js actually runs. Its job is to start an HTTP server and tell it to listen on a port. It imports app.js and says: take this Express application, put it on port 3000. It knows nothing about routes, databases, or business logic. Intentionally.

app.js is where Express is born. This is where the Express instance is created, global middleware is registered (like express.json() so the server can parse JSON request bodies), and routers are mounted. The reason it lives separately from server.js is testability — in tests, you import app.js directly without booting an actual server.

.env holds secrets: database connection strings, JWT secret keys, port numbers. This file is never committed to Git. .gitignore ensures that. env.example is a committed shadow of .env — same keys, empty values — so every developer on the team knows exactly what environment variables they need.


src/common/ — Shared Infrastructure

Think of common/ as the electrical wiring inside the walls. Nobody sees it, but everything runs on it.

config/db.js holds one job: connect to MongoDB via Mongoose. It exports a function you call once at startup. It lives in config/ because it is a configuration concern — setting up a dependency, not implementing a feature. Switching databases tomorrow means changing exactly this one file.

dto/base.dto.js defines a base class with the shared validation engine. DTO stands for Data Transfer Object — a structure that validates incoming request data before your application touches it. The base class handles the how of validation using Joi. Specific DTOs — RegisterDTO, LoginDTO — extend this base class and define the what: which fields are required, what format they must be in, what constraints apply. Inheritance is used with purpose here: write the validation plumbing once, reuse it across every feature.

middleware/validate.middleware.js is a middleware factory — a function that returns a middleware function. You call it as validate(RegisterDTO), and it hands back a middleware that Express will run on the request. That middleware instantiates the DTO with req.body, runs validation, and either passes the request forward or throws an error. One file. Used on every route that needs validation.

utils/api-error.js is a custom Error class that extends JavaScript's built-in Error. The problem with a plain throw new Error() in a web server is that errors need HTTP status codes. A "user not found" situation is a 404. A "not authorized" situation is a 401. A validation failure is a 400. This class packages a statusCode, a message, and optional extra details together so the global error handler in app.js always knows exactly what to send back.

utils/api-response.js is the mirror image — it wraps successful responses into a consistent shape. Every success response from this server looks like { success: true, message: "...", data: {...} }. Without this utility, every controller constructs this object manually with inconsistent field names and structures. This file is the single source of truth for what success looks like.

utils/jwt.utils.js exports two functions: one that generates a JWT by signing a payload with the secret key, and one that verifies an incoming token and decodes it. These are pure utilities — stateless, reusable, completely ignorant of which module is calling them.


src/modules/auth/ — The Authentication Feature

Everything inside modules/auth/ knows about one thing: authentication. If this folder were deleted, common/ would be completely unaffected and fully usable by other modules.

dto/ contains auth-specific schemas — RegisterDTO defines that name, email, and password are required, email must be a valid email format, password must meet a minimum length. LoginDTO defines that email and password are required. Both extend base.dto.js. The validation rules live here; the validation engine lives in the base class.

auth.model.js is the Mongoose schema. It defines what a User document looks like in MongoDB: fields, types, required flags, unique constraints. It also contains a pre-save hook that hashes the password using bcrypt before any user document is ever saved. This is enforced at the model level — no service or controller can accidentally save a plain-text password because the model makes it structurally impossible.

auth.routes.js is the router — pure traffic control. It maps HTTP verbs and URL paths to sequences of middleware and controller functions. For /register, it wires together validate(RegisterDTO) and then AuthController.register. Routes are intentionally thin. They compose pieces of logic in the right order without containing any logic themselves.

auth.controller.js is the bridge between HTTP and your business logic. It receives req and res, extracts the already-validated data from req.body, calls the appropriate service method, and sends back an ApiResponse. Controllers do not query the database. They do not make business decisions. They translate HTTP into service calls and service results into HTTP responses.

auth.service.js is where business logic actually lives. The register() function here checks if the email already exists (throws a 409 if it does), creates the user, generates a JWT, sends the verification email. The service has no knowledge of req or res. It takes plain data in and returns plain data out. This means it can be tested without a running server and called from anywhere in the codebase.

auth.middleware.js is the authentication guard. It reads the Authorization header, extracts the Bearer token, verifies it using jwt.utils.js, fetches the corresponding user, and attaches them to req.user. Protected routes add this middleware before the controller. If the token is missing, expired, or tampered with, a 401 error is thrown before the controller is ever reached.


Business Logic — The Rules Your Code Must Enforce

Business logic is not code. It is the set of rules that are true about your specific application — rules that no framework will ever define for you. The entire point of writing business logic well is that you should be able to explain each step to a non-developer and have it make complete sense.

The most important habit you can build is this: before you write a function, write the steps in plain English. If you cannot explain the logic in plain language, you are not ready to write it in JavaScript.


Authentication Business Logic

The guarantee of an authentication system is: only the right person, proving they are who they say they are, can access protected resources. Every function in this module serves that guarantee.


Registration — Building the Relationship

Validate the input first. Always. Before touching the database, confirm that name, email, and password are present and in the correct format. Validation is a gate. Nothing gets through without meeting the contract.

Check for existing users. After validation, query the database by email. If a user already exists with that address, stop immediately and respond with 409 Conflict. The request was structurally valid — the problem is a conflict with existing state. This distinction matters for whoever is building the frontend.

Hash the password. Never store a plain-text password. The model's pre-save hook runs the password through bcrypt, which produces an irreversible hash. Even if your database is breached, attackers get hashes, not passwords. bcrypt is designed to be computationally slow, making brute-force attacks expensive.

Create the user as unverified. The user is saved with isVerified: false. An unverified account exists in the system but cannot log in yet. The reason: you have not confirmed that the email provided actually belongs to this person.

Generate a verification token and send the email. A short-lived, signed token is embedded in a verification URL and sent to the provided email address. The act of clicking that link is the user proving they own the inbox.

Respond with 201 Created. Not a JWT. Not a session. A confirmation that the account was created and verification is pending.


Email Verification — Proving Identity

Receive and validate the token. The user clicks the link, hitting /verify with the token. Check it immediately — is it a valid JWT? Has it expired? If anything is wrong, throw an error. Expired tokens should produce a clear message with a "resend verification email" option.

Flip the verified flag. If the token is valid, find the associated user and set isVerified: true. Clear the token — it is single-use. Submitting the same link twice must fail.

Respond or issue a session. Some systems respond with a success message. Others immediately issue a JWT and consider the user logged in. Both are valid. This is a product decision, not a technical one.


Login — Recognizing a Returning User

Validate input. Same principle. Confirm email and password are present and correctly formatted.

Look up the user by email. If no user matches, respond with a deliberately vague 401 Unauthorized: Invalid credentials. Never say "email not found" — that tells an attacker which emails exist in your system. This attack is called user enumeration, and vague error messages prevent it.

Check isVerified. Before comparing passwords, confirm the account is verified. If not, respond with a specific message: "Please verify your email before logging in." This is a business rule. An unverified account is an incomplete account.

Compare the password hash. Use bcrypt.compare(submittedPassword, storedHash). Bcrypt is resistant to timing attacks — the comparison takes the same amount of time whether the password is correct or not.

Issue a JWT. If the hash matches, sign a JWT containing the user's id and role. Set an expiry — 7d for regular sessions, 30d for "remember me." Send this token back. Every future request from this user will include it in the Authorization header, and auth.middleware.js will verify it.


Where Each Step Lives

The division rule is simple:

— If a step validates the shape of incoming data → DTO — If a step makes a business decision or touches the database → Service — If a step talks to req or resController — If a step is a reusable utility → common/utils/

Every step has exactly one home.


E-Commerce Business Logic (Reference — Build When Auth Is Complete)

This section is a reference map for when you begin building the e-commerce layer. Read it now to understand the thinking, but implement it after the authentication module is fully working. The same design principles apply — the only difference is the domain.


Product Catalog Logic

A product is not just a name and a price. Its full shape includes name, description, price, category, images (array of URLs), stock (units available), isActive (whether it is currently listed), and potentially variants for size or color.

Key business decisions to make upfront: Can a product have zero stock and still be visible? (Usually yes, displayed as "out of stock.") Can an admin remove a product without deleting it? (Yes — set isActive: false. Soft deletion is almost always the right choice over hard deletion because it preserves order history.)

Product creation is admin-only. This means product routes are protected by two layers of middleware: first auth.middleware.js (is this person logged in?) and then a role-checking middleware (is this person an admin?). Two separate, composable middleware functions.


Cart Logic

When a user adds a product, check if it is already in their cart. If it is, increment the quantity rather than creating a duplicate entry. If the product is out of stock, reject the addition immediately — stock validation happens at add-to-cart time, not only at checkout.

Prices in the cart are not locked at the time of adding. The cart stores a reference to the product, and the current price is fetched dynamically when the cart is displayed. The final price is calculated server-side at checkout — never trust a price sent from the client.


Order Logic

An order is what a cart becomes after successful payment. The transition must be atomic — either everything succeeds or nothing does:

Verify every item is still in stock → calculate the total price server-side → create an Order with status "pending" → trigger payment → if payment succeeds, set status to "confirmed", decrement stock of every purchased item, clear the cart → if payment fails, set status to "failed", do not touch stock.

A crash between "payment succeeded" and "stock decremented" leaves data inconsistent. This is exactly the scenario MongoDB multi-document transactions exist to handle.


How the Modules Divide

modules/
  auth/        — everything we've already built
  products/    — product model, controller, service, routes + admin middleware
  cart/        — cart model, controller, service, routes
  orders/      — order model, controller, service, routes, payment integration
  payments/    — a service wrapping Razorpay or Stripe

common/ gains: a role-checking middleware, optionally a Redis caching utility, and an order confirmation mail utility.

The same three-layer mental model — infrastructure, common, modules — scales to the full e-commerce platform without any structural changes. The thinking that produced the auth module is the same thinking that produces everything else.


The Meta-Principle: Structure Reflects Thinking

The folder tree you see in VS Code is not a convention copied from Stack Overflow. It is a direct translation of a set of questions answered in the right order:

What does this feature guarantee? What are the exact steps to deliver that guarantee? Which steps validate data, which make decisions, which respond over HTTP? Which steps are shared across features, and which belong to one module?

The answers to those questions produce the files. The files do not produce the answers.

In the sections that follow, we will open each file and read the actual code — knowing now exactly why it exists, what it is responsible for, and what would break if it were removed.

3 views

Web Development

Part 2 of 7

📌 Description This series documents my journey into backend development, where I break down concepts not just by what they do, but how and why they work under the hood. Instead of treating backend as a black box, I focus on: Understanding internal workings (like how servers handle requests, data flow, etc.) Connecting concepts to real-world systems and analogies Writing and analyzing code step-by-step Each article is designed to take you from confusion to clarity — whether it's HTTP methods, APIs, server architecture, or deeper backend fundamentals. 🎯 What You’ll Learn . Core backend concepts explained intuitively . How things actually work behind the scenes . Practical code implementations with breakdowns . Real-world parallels to simplify complex ideas 🚀 Who This Is For Beginners starting backend development Frontend developers curious about what happens “behind the API” Anyone who wants deeper clarity instead of surface-level tutorials 📖 Approach I’m learning and building in public — which means this series is honest, evolving, and focused on true understanding rather than memorization.

Up next

Authentication, Authorization, Tokens & the Complete Security Layer

The Problem Every Web Application Has to Solve HTTP has no memory. Every single request your browser sends to a server is treated as if it came from a stranger. The server does not know if you logged

More from this blog