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,
};