Skip to main content

Refactoring playground

A vibe-coded Express API in one file, rebuilt into features with clean layers. Pick a change to see the problem, the fix and the reasoning.

Before
1 file, 79 lines
After
20 files, 254 lines
Security fixes
8
Features
auth, products, orders

Move secrets out of the code

CriticalSecurity

The problem

The database password and the JWT signing key were committed in plain text. Anyone with a copy of the repo could sign their own admin token.

The fix and why

Secrets now come from environment variables, with a .env.example for setup. The app refuses to start if one is missing instead of falling back to a default.

before / server.js79 lines
1const express = require("express");
2const mysql = require("mysql2");
3const jwt = require("jsonwebtoken");
4
5const app = express();
6app.use(express.json());
7
8const db = mysql.createConnection({
9 host: "localhost",
10 user: "root",
11 password: "kedai123",
12 database: "kedai",
13});
14
15const JWT_SECRET = "supersecret";
16
17// login
18app.post("/login", (req, res) => {
19 const { email, password } = req.body;
20 db.query(
21 "SELECT * FROM users WHERE email = '" + email + "' AND password = '" + password + "'",
22 (err, rows) => {
23 if (err) return res.status(500).json({ error: err.message, stack: err.stack });
24 if (rows.length == 0) return res.status(401).json({ error: "wrong email or password" });
25 const token = jwt.sign({ id: rows[0].id, role: rows[0].role }, JWT_SECRET);
26 res.json({ token, user: rows[0] });
27 }
28 );
29});
30
31// products
32app.get("/products", (req, res) => {
33 let sql = "SELECT * FROM products";
34 if (req.query.search) {
35 sql += " WHERE name LIKE '%" + req.query.search + "%'";
36 }
37 db.query(sql, (err, rows) => {
38 if (err) return res.status(500).json({ error: err.message, stack: err.stack });
39 res.json(rows);
40 });
41});
42
43app.get("/products/:id", (req, res) => {
44 db.query("SELECT * FROM products WHERE id = " + req.params.id, (err, rows) => {
45 if (err) return res.status(500).json({ error: err.message, stack: err.stack });
46 res.json(rows[0]);
47 });
48});
49
50// orders
51app.post("/orders", (req, res) => {
52 const { userId, items, total } = req.body;
53 db.query(
54 "INSERT INTO orders (user_id, total) VALUES (" + userId + ", " + total + ")",
55 (err, result) => {
56 if (err) return res.status(500).json({ error: err.message, stack: err.stack });
57 const orderId = result.insertId;
58 items.forEach((item) => {
59 db.query(
60 "INSERT INTO order_items (order_id, product_id, qty, price) VALUES (" +
61 orderId + ", " + item.productId + ", " + item.qty + ", " + item.price + ")"
62 );
63 db.query("UPDATE products SET stock = stock - " + item.qty + " WHERE id = " + item.productId);
64 });
65 res.json({ orderId, total });
66 }
67 );
68});
69
70app.get("/orders", (req, res) => {
71 const token = req.headers.authorization;
72 const user = jwt.decode(token);
73 db.query("SELECT * FROM orders WHERE user_id = " + user.id, (err, rows) => {
74 if (err) return res.status(500).json({ error: err.message, stack: err.stack });
75 res.json(rows);
76 });
77});
78
79app.listen(3000, () => console.log("server running on 3000"));
13 lines
1import "dotenv/config";
2
3// Fail fast: never boot with a missing secret or a default password.
4for (const key of ["DATABASE_URL", "JWT_SECRET"]) {
5 if (!process.env[key]) throw new Error(`Missing environment variable: ${key}`);
6}
7
8export const env = {
9 port: Number(process.env.PORT ?? 3000),
10 databaseUrl: process.env.DATABASE_URL,
11 jwtSecret: process.env.JWT_SECRET,
12 jwtExpiresIn: "1h",
13};

How the new structure works

Each feature folder has the same layers, and dependencies only point downward.

routes
HTTP only: the URL, the validation schema, the status code. No SQL and no business rules, so a route reads in a few lines.
service
The business rules, such as prices come from the database and stock cannot go negative. Plain functions, testable without a server or a database.
repository
The only place that writes SQL, always parameterized. Swap MySQL for Postgres and nothing above this layer changes.
shared
Config, the database pool, auth, validation and error handling. Written once and used by every feature, instead of copy-pasted into each route.