This is how I code my backend
  • setup
  • web dev

March 10, 2025

Lately I have shifted my focus towards Postgresql, with postgres I have been following this structure for my full stack projects.

For backend, I mostly use express.js for http server, zod for validation and postgres for database. With this structure My main focus is good development experience. I don't use any ORM. I don't see any benefit of using ORM over writing RAW queries, It is for someone who don't know how to write SQL queries.

setup

pnpm i express bcrypt cors dotenv jsonwebtoken winston zod pg
pnpm i --save-dev typescript ts-node @types/node @types/express nodemon @types/jsonwebtoken @types/bcrypt @types/cors @types/pg
npx tsc --init

// src/tscofig.json
{
  "compilerOptions": {
    "target": "ES6",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}
// nodemon.json
{
  "watch": ["src"],
  "ext": "ts",
  "exec": "ts-node src/app.ts"
}
// package.json
"scripts": {
  "start": "node dist/app.js",
  "dev": "nodemon src/app.ts",
  "build": "tsc"
}

Folder structure

# FOLDER STRUCTURE
backend/
│── src/
   ├── config/           # Configuration files (DB, env, constants)
   ├── controllers/      # Controller functions (business logic)
   ├── middleware/       # Express middleware (auth, error handling, etc.)
   ├── models/           # Database models and queries (raw SQL)
   ├── routes/           # Express route handlers
   ├── services/         # Business logic and reusable services
   ├── sockets/          # Socket.io event handlers
   ├── utils/            # Utility functions (helpers, formatters)
   ├── validators/       # Zod validation schemas
   ├── app.ts            # Express app instance
   ├── server.ts         # Server entry point
│── .env                  # Environment variables
│── package.json
│── tsconfig.json
│── nodemon.json
│── README.md

// config/db.ts
import { Pool } from "pg";
import dotenv from "dotenv";

dotenv.config();

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export default pool;
// src/server.ts
import { server } from "./app";

const PORT = process.env.PORT || 5000;

server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
// src/app.ts
import express from "express";
import cors from "cors";
import { createServer } from "http";
import { Server } from "socket.io";
import authRouter from "./routes/auth.router";
import { logRequest } from "./middleware/log-request.middleware";

const app = express();
const server = createServer(app);
const io = new Server(server, { cors: { origin: "*" } });

// express middlewares
app.use(express.json());
app.use(cors());

// app middleware
app.use(logRequest);

// routes
app.use("/auth", authRouter);

// setupOrderSockets(io);

export { app, server };
// routes/auth.router.ts
import { Router } from "express";
import { validateRequest } from "../middleware/validate-request.middleware";
import { registerSchema } from "../validators/auth.validator";

const authRouter = Router();

authRouter.post(
  "/register",
  validateRequest(registerSchema),
  handleRegisterUser
);
// middlewares/validate-request.middleware.ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import { ZodSchema, ZodError } from "zod";

import { ApiError } from "../utils/api-error";
import { formatZodErrors } from "../utils/format-zod-error";

export const validateRequest = (schema: ZodSchema): RequestHandler => {
  return (req: Request, res: Response, next: NextFunction): void => {
    try {
      // If a file is uploaded, add the image path to req.body
      if (req.file) {
        req.body.image = `\\${req.file.path}`;
      }

      // Validate the body, query, and params based on the schema
      schema.parse({
        body: req.body, // Parse request body
        query: req.query, // Parse query params
        params: req.params, // Parse route params
      });

      // Proceed to the next middleware/route handler
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        // Format the Zod validation errors and pass them to ApiError
        const formattedErrors = formatZodErrors(error);
        next(new ApiError(400, "Input Validation Failed", formattedErrors));
      } else {
        // If error isn't a ZodError, pass it to the next middleware
        next(error);
      }
    }
  };
};
//controllers/auth.controller.ts
import { CookieOptions } from "express";
import { createUser } from "../models/auth.model";
import { ApiResponse } from "../utils/api-response";
import { asyncHandler } from "../utils/async-handler";
import { generateAccessToken, generateRefreshToken } from "../utils/jwt";

const isProduction = process.env.NODE_ENV === "production";
const cookieOptions: CookieOptions = {
  httpOnly: true,
  secure: isProduction,
  sameSite: isProduction ? "strict" : "lax",
};

export const handleRegisterUser = asyncHandler(async (req, res) => {
  const { name, email, password } = req.body;

  const {
    created_at,
    password: pw,
    updated_at,
    ...newUser
  } = await createUser(name, email, password);

  const accessToken = generateAccessToken({
    userId: newUser.id,
    roleId: newUser.role_id,
  });

  const refreshToken = generateRefreshToken({
    userId: newUser.id,
    roleId: newUser.role_id,
  });

  res
    .cookie("refreshToken", refreshToken, cookieOptions)
    .status(201)
    .json(
      new ApiResponse(
        201,
        { user: newUser, accessToken },
        "User Registered successfully"
      )
    );
});
// services/user.service.ts
import { ApiError } from "../utils/api-error";
import { createUser, FindUserByEmail } from "../models/auth.model";
import { hashPassword } from "../utils/bcrypt";

export class UserService {
  // Register a new user
  static async registerUser(username: string, email: string, password: string) {
    // Check if the email already exists
    const existingUser = await FindUserByEmail(email);
    if (existingUser) {
      throw new ApiError(400, "User with this email already exists");
    }

    // Hash the password
    const hashedPassword = await hashedPassword(password, 10);

    // Create and save the new user
    const newUser = await createUser(username, email, hashedPassword);

    return newUser; // Return the newly created user (without password)
  }
}
// models/auth.model.ts
import { query } from "../config/db";

export const createUser = async (
  name: string,
  email: string,
  password: string
) => {
  const roleResult = await query("SELECT id FROM roles WHERE name = $1", [
    "user",
  ]);

  if (roleResult.rows.length === 0) {
    throw new Error("Default role 'user' not found in roles table");
  }

  const roleId = roleResult.rows[0].id;

  const result = await query(
    "INSERT INTO users (name, email, password, role_id) VALUES ($1, $2, $3, $4) RETURNING *",
    [name, email, password, roleId]
  );
  return result.rows[0];
};

export const FindUserByEmail = async (email: string) => {
  const result = await query("SELECT * FROM users WHERE email $1", [email]);
  return result.rows;
};

// utils/api-error.ts
export class ApiError extends Error {
  public statusCode: number;
  public success: boolean;
  public errors: Record<string, string>;

  constructor(
    statusCode: number,
    message = "Internal Server Error",
    errors: Record<string, string> = {}
  ) {
    super(message);
    this.name = "ApiError";
    this.statusCode = statusCode;
    this.success = false;
    this.errors = errors;

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}
// utils/api-response.ts
export class ApiResponse<T> {
  public readonly statusCode: number;
  public readonly data: T | null;
  public readonly message: string;
  public readonly success: boolean;
  public readonly metadata?: Record<string, any>;

  constructor(
    statusCode: number,
    data: T | null,
    message = "API call successful",
    metadata?: Record<string, any>
  ) {
    this.statusCode = statusCode;
    this.data = data;
    this.message = message;
    this.success = statusCode >= 200 && statusCode < 400;
    this.metadata = metadata;
  }
}
// utils/async-handler.ts
import { NextFunction, Request, RequestHandler, Response } from "express";

const asyncHandler = (requestHandler: RequestHandler): RequestHandler => {
  return (req: Request, res: Response, next: NextFunction): void => {
    Promise.resolve(requestHandler(req, res, next)).catch(next);
  };
};

export { asyncHandler };
// utils/bcrypt.ts
import bcrypt from "bcrypt";

const SALT_ROUNDS = 10;

export async function hashPassword(password: string): Promise<string> {
  const salt = await bcrypt.genSalt(SALT_ROUNDS);
  return bcrypt.hash(password, salt);
}

export async function comparePassword(
  candidatePassword: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(candidatePassword, hash);
}
// utils/format-zod-errors.ts
import { ZodError } from "zod";

export function formatZodErrors(error: ZodError): Record<string, string> {
  const formattedErrors: Record<string, string> = {};
  const errorDetails = error.format() as Record<string, { _errors?: string[] }>;

  for (const key in errorDetails) {
    if (key !== "_errors" && errorDetails[key]._errors) {
      formattedErrors[key] = errorDetails[key]._errors!.join(", ");
    }
  }

  return formattedErrors;
}
// utils/jwt.ts
import jwt from "jsonwebtoken";
import { config } from "../config/env";

interface JwtPayload {
  userId: number;
  roleId: number;
}

const ACCESS_TOKEN_SECRET = config.accessTokenSecret;
const REFRESH_TOKEN_SECRET = config.refreshTokenSecret;
const ACCESS_TOKEN_EXPIRY = config.accessTokenExpiry;
const REFRESH_TOKEN_EXPIRY = config.refreshTokenExpiry;

export function generateAccessToken(payload: JwtPayload): string {
  return jwt.sign(payload, ACCESS_TOKEN_SECRET, {
    expiresIn: ACCESS_TOKEN_EXPIRY,
  });
}

export function generateRefreshToken(payload: JwtPayload): string {
  return jwt.sign(payload, REFRESH_TOKEN_SECRET, {
    expiresIn: REFRESH_TOKEN_EXPIRY,
  });
}

export function verifyAccessToken(token: string): JwtPayload | null {
  try {
    return jwt.verify(token, ACCESS_TOKEN_SECRET) as JwtPayload;
  } catch (error) {
    return null;
  }
}

export function verifyRefreshToken(token: string): JwtPayload | null {
  try {
    return jwt.verify(token, REFRESH_TOKEN_SECRET) as JwtPayload;
  } catch (error) {
    return null;
  }
}
// utils/logger.ts
import winston from "winston";

// Define log levels and formats
const logLevels = {
  levels: {
    info: 0,
    warn: 1,
    error: 2,
  },
  colors: {
    info: "green",
    warn: "yellow",
    error: "red",
  },
};

// Create a custom format for logging
const logFormat = winston.format.printf(({ timestamp, level, message }) => {
  return `${timestamp} [${level}]: ${message}`;
});

// Create the logger instance
const logger = winston.createLogger({
  levels: logLevels.levels,
  transports: [
    // Console transport (for development)
    new winston.transports.Console({
      level: "info", // Minimum level to log
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.timestamp(),
        logFormat
      ),
    }),
    // File transport (optional, for production)
    new winston.transports.File({
      filename: "logs/app.log",
      level: "info", // Minimum level to log
      format: winston.format.combine(winston.format.timestamp(), logFormat),
    }),
  ],
});

// Add colors to log levels (optional, for console output)
winston.addColors(logLevels.colors);

export default logger;

// types/express.d.ts
import { Request } from "express";

export interface AuthenticatedRequest extends Request {
  user?: {
    userId: number;
    roleId: number;
  };
}
declare global {
  namespace Express {
    interface Request {
      file?: Express.Multer.File; // Add the 'file' property
    }
  }
}
// config/env.ts
import * as dotenv from "dotenv";
import { z } from "zod";

dotenv.config();

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().min(1024).max(65535).default(3000),
  NODE_ENV: z
    .enum(["development", "production", "test"])
    .default("development"),
  ACCESS_TOKEN_SECRET: z.string().min(6),
  REFRESH_TOKEN_SECRET: z.string().min(6),
  ACCESS_TOKEN_EXPIRY: z.string().min(6), // ex: 1d
  REFRESH_TOKEN_EXPIRY: z.string().min(6), // ex: 7d
});

const env = envSchema.parse(process.env);

interface Config {
  databaseUrl: string;
  port: number;
  nodeEnv: "development" | "production" | "test";
  accessTokenSecret: string;
  refreshTokenSecret: string;
  accessTokenExpiry: string;
  refreshTokenExpiry: string;
}

export const config: Config = {
  databaseUrl: env.DATABASE_URL,
  port: env.PORT,
  nodeEnv: env.NODE_ENV,
  accessTokenSecret: env.ACCESS_TOKEN_SECRET,
  refreshTokenSecret: env.REFRESH_TOKEN_SECRET,
  accessTokenExpiry: env.ACCESS_TOKEN_EXPIRY,
  refreshTokenExpiry: env.REFRESH_TOKEN_EXPIRY,
};