| Backend là gì? Toàn bộ cách một hệ thống web vận hành phía sau

Được viết bởi Đặng Trí Thanh vào ngày 01/10/2026 lúc 08:40 | 14 lượt xem

Bài viết thuộc loạt kiến thức nền tảng của Hướng Nghiệp Dữ Liệu. Toàn bộ ví dụ trong bài lấy từ một hệ thống thật đang chạy: app đặt lịch cho tiệm nail Vietnam Nails tại Helsinki, viết bằng Express + PostgreSQL. Dành cho người mới bắt đầu học backend và cho người đã viết frontend muốn hiểu nốt nửa còn lại của hệ thống.

Backend là toàn bộ phần chạy phía sau, người dùng không nhìn thấy nhưng luôn phụ thuộc vào.

Tóm tắt cho người bận

  • Backend là phần mềm chạy trên máy chủ, làm bốn việc: xử lý logic, lưu dữ liệu, xác thực người dùng, và trả kết quả cho client.
  • Người dùng không bao giờ nhìn thấy backend — họ chỉ thấy kết quả của nó qua giao diện.
  • Một request đi qua 8 chặng: DNS, TCP/TLS, reverse proxy, web server, middleware, route, service, database.
  • Trong dự án thật của tôi, backend có 141 endpoint và xử lý ví, đặt lịch, tin nhắn, tính lương — những việc frontend không được phép làm.
  • Ba lỗi phổ biến nhất của người mới học backend: nhồi hết logic vào controller, không kiểm tra dữ liệu đầu vào, và để lộ dữ liệu nhạy cảm trong response.
  • Bài có checklist 12 điểm để tự rà lại backend đang viết.

Mục lục

  1. Backend là gì?
  2. Kiến trúc và nguyên lý hoạt động
  3. Ví dụ thực tế: hệ thống đặt lịch tiệm nail
  4. Code: từ ví dụ tối giản đến nghiệp vụ thật
  5. 5 lỗi thường gặp khi học backend
  6. Security: những thứ phải làm ngay từ đầu
  7. Performance: ba thứ quyết định tốc độ
  8. Testing: kiểm thử backend thế nào?
  9. Deployment: đưa backend lên chạy thật
  10. Checklist 12 điểm
  11. Câu hỏi thường gặp

1. Backend là gì?

Backend là toàn bộ phần mềm chạy trên máy chủ (server), chịu trách nhiệm xử lý logic nghiệp vụ, lưu trữ dữ liệu, xác thực người dùng và cung cấp dữ liệu cho giao diện thông qua các API. Nói cách khác: frontend là phần bạn nhìn thấy, backend là phần bạn phụ thuộc vào nhưng không thấy.

Định nghĩa này nghe đơn giản, nhưng nó chứa một hệ quả quan trọng: mọi thứ quan trọng đều nằm ở backend. Giá tiền, số dư ví, quyền hạn, lịch đặt, lịch sử giao dịch — tất cả những dữ liệu mà nếu sai thì doanh nghiệp mất tiền — đều do backend quyết định. Frontend chỉ hiển thị lại.

1.1. Bốn nhiệm vụ cốt lõi

Dù viết bằng ngôn ngữ nào, backend nào cũng làm bốn việc:

Nhiệm vụ Nghĩa là gì Ví dụ thật
Xử lý logic Áp quy tắc nghiệp vụ lên dữ liệu Đặt lịch trùng giờ thì phải chặn; thanh toán bằng ví thì phải trừ tiền
Lưu trữ Đọc/ghi dữ liệu bền vững Lưu khách hàng, dịch vụ, lịch hẹn vào PostgreSQL
Xác thực & phân quyền Biết ai đang gọi và được phép làm gì Chỉ quản trị viên được đổi giá dịch vụ; khách chỉ xem được lịch của mình
Giao tiếp Cung cấp API cho frontend và hệ thống khác Trả JSON cho app React, gửi dữ liệu cho đối tác qua API tích hợp

1.2. Phân biệt frontend và backend

Hai khái niệm này hay bị trộn lẫn. Bảng dưới đây tách rõ:

Frontend Backend
Chạy ở đâu Trình duyệt hoặc app của người dùng Máy chủ, trong trung tâm dữ liệu
Người dùng thấy Có — toàn bộ giao diện Không — chỉ thấy kết quả
Ai kiểm soát được code Người dùng (xem, sửa, chặn) Chỉ nhà phát triển
Có tin được không Không bao giờ Có — nhưng phải tự kiểm tra lại
Ngôn ngữ phổ biến JavaScript, TypeScript, Dart Node.js, Python, Java, Go, C#, PHP
Dữ liệu ở đâu Chỉ trong bộ nhớ tạm Database, cache, file storage

Dòng quan trọng nhất trong bảng trên là dòng thứ tư. Mọi thứ chạy trên máy người dùng đều có thể bị sửa. Nếu bạn kiểm tra quyền ở frontend rồi tin tưởng rằng backend sẽ không bị gọi trực tiếp — bạn đã sai. Tôi đã từng để lộ chính xác lỗi này trong dự án thật và viết hẳn một bài case study về nó (xem mục Bảo mật REST API: 14 lỗ hổng).

1.3. Ví von quán ăn

Cách dễ nhất để nhớ vai trò từng phần là hình dung một quán ăn:

Khách ngồi ở bàn         → Người dùng (frontend)
Menu, ánh sáng, không khí → Giao diện
Người phục vụ nhận order  → API — cầu nối giữa hai bên
Đầu bếp                  → Logic nghiệp vụ (backend)
Kho nguyên liệu           → Database
Quản lý ca                → Xác thực & phân quyền
Sổ ghi doanh thu          → Log, audit trail

Khách không được phép đi thẳng vào bếp. Cũng vậy, frontend không được phép ghi thẳng vào database. Mọi yêu cầu phải qua “người phục vụ” — tức là API — và người phục vụ phải kiểm tra xem yêu cầu đó có hợp lệ không trước khi chuyển xuống bếp.

1.4. Một backend gồm những gì?

Người mới thường nghĩ backend chỉ là “code server”. Thực tế một backend hoàn chỉnh gồm nhiều lớp:

Lớp Vai trò Ví dụ trong dự án thật
Ngôn ngữ & runtime Chạy code Node.js 20 + TypeScript
Framework Định tuyến, middleware Express 4.21
Database Lưu dữ liệu PostgreSQL
ORM / query layer Làm việc với database an toàn Drizzle ORM
Validation Chặn dữ liệu sai từ đầu vào Zod
Auth Chứng minh danh tính, cấp quyền JWT + middleware phân quyền
Hạ tầng chạy Vận hành 24/7 Windows Server tự quản + Render
Log & monitoring Biết hệ thống đang thế nào Log file + endpoint /api/health

1.5. Backend không phải là gì

Ba hiểu nhầm phổ biến:

  • Backend không phải là “cơ sở dữ liệu”. Database chỉ là một phần backend dùng đến. Backend vẫn có ý nghĩa ngay cả khi chưa có database — ví dụ tính toán, kiểm tra dữ liệu, gọi dịch vụ bên ngoài.
  • Backend không chỉ là CRUD. Nếu backend của bạn chỉ là thêm/sửa/xoá/xem thì bạn chưa động tới phần khó: giao dịch, phân quyền, tính nhất quán dữ liệu, xử lý lỗi.
  • Backend không phải “admin panel”. Admin panel là một client khác gọi vào cùng backend đó. Nó không phải là backend.

2. Kiến trúc và nguyên lý hoạt động

Để hiểu backend, cách nhanh nhất là đi theo một request từ lúc người dùng bấm nút đến lúc nhìn thấy kết quả. Quãng đường đó có tám chặng.

2.1. Vòng đời của một request

1. Người dùng bấm "Đặt lịch"
        ↓
2. DNS: đổi tên miền thành địa chỉ IP
        ↓
3. TCP + TLS: mở kết nối, mã hoá
        ↓
4. Reverse proxy (Nginx / Cloudflare): định tuyến, cache, chống DDoS
        ↓
5. Web server / app server (Node.js + Express): nhận request
        ↓
6. Middleware: xác thực token → kiểm tra quyền → validate dữ liệu
        ↓
7. Route → Controller → Service: chạy logic nghiệp vụ
        ↓
8. Database / cache / dịch vụ ngoài: đọc ghi dữ liệu
        ↓
9. Response: trả JSON về cho client

Điểm quan trọng nằm ở chặng 6 và 7: backend phải kiểm tra trước rồi mới làm, không phải làm rồi hy vọng đúng. Toàn bộ các lỗ hổng bảo mật nghiêm trọng nhất đều là do bỏ qua một bước ở chặng 6.

2.2. Middleware — khái niệm then chốt

Middleware là các hàm chạy trước khi request tới được logic xử lý. Mỗi hàm có thể: cho đi tiếp, chặn lại, hoặc sửa request.

// Thứ tự middleware quan trọng — chạy từ trên xuống
app.use(express.json());        // 1. Đọc body JSON
app.use(requestLogger);         // 2. Ghi log
app.use(rateLimit);             // 3. Chặn spam
app.use(authenticate);          // 4. Xác thực token
app.use("/api/admin", requireAdmin);  // 5. Kiểm tra quyền
app.use("/api/bookings", bookingRoutes);  // 6. Vào logic

Nếu bạn đặt requireAdmin sau route xử lý, quyền sẽ không bao giờ được kiểm tra. Đây là dạng lỗi im lặng nguy hiểm nhất trong Express: code vẫn chạy, test thủ công vẫn thấy đúng, nhưng hệ thống đang mở.

2.3. Stateless — vì sao backend không nên nhớ

Backend hiện đại được thiết kế stateless: mỗi request tự mang đủ thông tin để xử lý, server không lưu trạng thái của người dùng trong bộ nhớ.

Lý do rất thực tế: nếu server lưu phiên đăng nhập trong RAM, thì khi bạn chạy hai bản server để chịu tải, người dùng sẽ bị “đăng xuất” ngẫu nhiên tuỳ lần nào xử lý request. Token (JWT) giải quyết việc này vì thông tin nằm trong chính token do client gửi lên.

2.4. Các mô hình kiến trúc thường gặp

Mô hình Khi nào dùng Đánh đổi
Monolith Đội nhỏ, sản phẩm chưa rõ hình dạng, cần đi nhanh Dễ làm, nhưng lớn dần sẽ khó tách
Modular monolith Đang là monolith nhưng muốn sạch sẽ Chi phí thấp, biên giới module rõ
Microservices Nhiều đội, tải rất khác nhau giữa các phần Rất tốn công vận hành, cần hạ tầng mạnh
Serverless Tải thất thường, không muốn quản server Rẻ khi ít tải, khó debug khi lỗi

Với một tiệm nail hoặc một doanh nghiệp nhỏ, modular monolith là lựa chọn đúng. Chia microservices quá sớm là sai lầm tốn kém: bạn đổi một vấn đề về code lấy ba vấn đề về hạ tầng.

3. Ví dụ thực tế: hệ thống đặt lịch tiệm nail

Để backend không còn là khái niệm trừu tượng, tôi lấy chính hệ thống đang chạy của mình. Đây là app đặt lịch cho Vietnam Nails — tiệm nail tại Helsinki, Phần Lan.

3.1. Bài toán

Chủ tiệm cần một hệ thống thay thế sổ giấy và tin nhắn WhatsApp, cụ thể:

  • Khách tự đặt lịch, chọn dịch vụ, chọn thợ, chọn giờ trống
  • Khách nạp tiền vào ví và trả bằng ví
  • Khách chat trực tiếp với chủ tiệm trong app
  • Chủ tiệm quản lý lịch, chấm công, tính lương, xuất Excel
  • Hệ thống gửi email nhắc hẹn tự động
  • Giao diện ba ngôn ngữ: Việt, Anh, Phần Lan

Nhìn qua thì đây là “một app đặt lịch”. Nhưng mỗi gạch đầu dòng ở trên đều là một quyết định backend, và làm sai một cái là mất tiền thật.

3.2. Quy mô thật của backend

Tôi đếm số endpoint trong mã nguồn bằng cách tìm tất cả các khai báo route:

grep -cE "app\.(get|post|put|patch|delete|all)\(" server/routes.ts
# 141

141 endpoint. Con số này quan trọng vì nó tỉ lệ với số chỗ có thể bị bỏ sót. Ban đầu hệ thống chỉ có 41 chỗ kiểm tra quyền (21 dùng requireAdmin, 20 dùng requireOwner) — nghĩa là phần lớn endpoint không kiểm tra gì cả. Sau khi rà và sửa, con số thành 73 chỗ (52 + 21).

3.3. Vì sao những việc này buộc phải ở backend?

Đây là câu hỏi mà người mới học hay bỏ qua. Ta thử làm ngược lại — đặt logic ở frontend — và xem điều gì xảy ra:

Việc Nếu để frontend làm Hậu quả
Tính giá dịch vụ Frontend gửi giá lên cùng request đặt lịch Khách sửa giá thành 0 € trong DevTools — mất tiền thật
Trừ tiền ví Frontend tự trừ rồi báo lên Đặt 2 lần liên tiếp cùng lúc → âm ví
Chặn trùng giờ Frontend kiểm tra giờ trống Hai khách đặt cùng slot → một người bị từ chối phút cuối
Phân quyền Ẩn nút “Xoá” với người không phải admin Gọi thẳng API bằng curl là xoá được

Quy tắc vàng: frontend chỉ hiển thị, backend mới quyết định. Mọi kiểm tra ở frontend đều là “trải nghiệm người dùng”, không phải bảo mật.

3.4. Luồng nghiệp vụ thật: đặt lịch và trả bằng ví

Đây là luồng phức tạp nhất trong hệ thống. Nó phải làm năm việc hoặc tất cả cùng thành công, hoặc không việc nào cả:

1. Kiểm tra slot còn trống không
2. Tính giá từ database — KHÔNG lấy giá từ client
3. Tạo bản ghi lịch hẹn
4. Trừ tiền trong ví khách
5. Ghi lại giao dịch để đối soát sau này

Nếu bước 4 thất bại mà bước 3 đã ghi, khách có lịch miễn phí. Nếu bước 3 thất bại mà bước 4 đã trừ, khách mất tiền oan. Vì vậy toàn bộ năm bước phải nằm trong một transaction.

3.5. Bài học từ hệ thống thật

Ba điều tôi học được khi vận hành hệ thống này:

  1. Backend không có “chức năng nhỏ”. Một endpoint sửa số dư ví trông rất nhỏ, nhưng thiếu kiểm tra quyền thì bất kỳ ai cũng nạp tiền cho mình được. Đây đúng là lỗi tôi từng có.
  2. Lỗi backend thường im lặng. Frontend sai thì thấy ngay trên màn hình. Backend sai thì API vẫn trả 200, chỉ có dữ liệu là sai — và vài tuần sau mới phát hiện khi đối soát tiền.
  3. Đo mới biết mình yếu ở đâu. Tôi từng nghĩ hệ thống ổn. Đến khi viết bộ test bảo mật tử tế, nó tìm ra 14 lỗ hổng, trong đó 6 mức nghiêm trọng.

4. Code: từ ví dụ tối giản đến nghiệp vụ thật

Ta đi từ một API nhỏ nhất rồi mở rộng dần. Toàn bộ code dưới đây theo phong cách dự án thật: Express + TypeScript + PostgreSQL.

4.1. Backend nhỏ nhất có thể

import express from "express";

const app = express();
app.use(express.json());

app.get("/api/health", (req, res) => {
  res.json({ ok: true, time: new Date().toISOString() });
});

app.listen(5103, () => console.log("Backend chạy ở cổng 5103"));

Ba dòng cốt lõi: tạo app, khai báo route, lắng nghe cổng. Mọi backend đều bắt đầu như thế này và lớn lên từ đây.

4.2. Thêm database

import { Pool } from "pg";

const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,                      // tối đa 20 kết nối
  idleTimeoutMillis: 30000,     // đóng kết nối rảnh sau 30s
});

app.get("/api/services", async (req, res) => {
  const { rows } = await pool.query(
    "SELECT id, name, price_cents FROM services WHERE active = true ORDER BY name"
  );
  res.json(rows);
});

Ba chi tiết đáng chú ý:

  • max: 20 — giới hạn kết nối. Không giới hạn thì khi có bão request, database sẽ bị sập vì hết kết nối.
  • Query dùng tham số, không nối chuỗi. Nối chuỗi là mở đường cho SQL Injection.
  • Giá lưu bằng số nguyên (price_cents), không dùng số thực. Số thực gây sai số làm tròn — với tiền thì không được phép.

4.3. Kiểm tra dữ liệu đầu vào

Đây là bước mà người mới hay bỏ nhất. Dữ liệu từ client luôn phải bị coi là không đáng tin:

import { z } from "zod";

const CreateBooking = z.object({
  serviceId: z.string().uuid(),
  staffId: z.string().uuid(),
  startAt: z.string().datetime(),
  paymentMethod: z.enum(["cash", "wallet"]),
});

app.post("/api/bookings", async (req, res) => {
  const parsed = CreateBooking.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({
      error: "Dữ liệu không hợp lệ",
      issues: parsed.error.issues.map((i) => ({
        field: i.path.join("."),
        message: i.message,
      })),
    });
  }
  // ... xử lý tiếp với parsed.data
});

Lưu ý safeParse chứ không phải parse. parse sẽ ném lỗi, và nếu bạn quên try/catch ở hàm async thì Express sẽ treo request mà không trả lời. Tôi từng dính đúng lỗi này: một console.error gặp ZodError làm hàm log tự ném lỗi bên trong khối catch, khiến năm endpoint treo hơn 60 giây không phản hồi. Chi tiết trong bài Bảo mật REST API.

4.4. Middleware xác thực và phân quyền

function requireAdmin(req, res, next) {
  const auth = req.headers.authorization || "";
  const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";

  if (!token) {
    return res.status(401).json({ error: "Thiếu token" });
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    if (payload.role !== "admin") {
      return res.status(403).json({ error: "Không đủ quyền" });
    }
    req.admin = payload;
    next();
  } catch {
    return res.status(401).json({ error: "Token không hợp lệ" });
  }
}

Ba trạng thái trả về khác nhau và phải khác nhau:

Mã Nghĩa Khi nào trả
401 Chưa xác thực Không có token, hoặc token sai/hết hạn
403 Đã xác thực nhưng không đủ quyền Token hợp lệ nhưng role không phải admin
200 Thành công Token hợp lệ và đủ quyền

Dùng 401 cho cả hai trường hợp là lỗi thiết kế: người dùng không biết nên đăng nhập lại hay xin thêm quyền.

4.5. Transaction — phần khó nhất

Đây là đoạn code thể hiện rõ nhất vì sao backend khó. Nghiệp vụ: khách đặt lịch và trả bằng ví. Năm bước phải cùng thành công hoặc cùng thất bại.

app.post("/api/bookings/pay-by-wallet", requireAuth, async (req, res) => {
  const parsed = CreateBooking.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: "Dữ liệu không hợp lệ" });
  }

  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    // 1. Khoá dòng ví để tránh hai request cùng trừ tiền
    const { rows: [wallet] } = await client.query(
      "SELECT id, balance_cents FROM wallets WHERE user_id = $1 FOR UPDATE",
      [req.user.id]
    );
    if (!wallet) {
      throw new HttpError(404, "Không tìm thấy ví");
    }

    // 2. Tính giá từ DATABASE, tuyệt đối không lấy từ client
    const { rows: [service] } = await client.query(
      "SELECT price_cents FROM services WHERE id = $1 AND active = true",
      [parsed.data.serviceId]
    );
    if (!service) {
      throw new HttpError(404, "Dịch vụ không tồn tại");
    }
    if (wallet.balance_cents < service.price_cents) {
      throw new HttpError(400, "Số dư không đủ");
    }

    // 3. Kiểm tra slot còn trống
    const { rows: clash } = await client.query(
      `SELECT 1 FROM bookings
        WHERE staff_id = $1 AND status <> 'cancelled'
          AND start_at = $2
        FOR UPDATE`,
      [parsed.data.staffId, parsed.data.startAt]
    );
    if (clash.length > 0) {
      throw new HttpError(409, "Khung giờ đã có người đặt");
    }

    // 4. Tạo lịch hẹn
    const { rows: [booking] } = await client.query(
      `INSERT INTO bookings (user_id, service_id, staff_id, start_at, price_cents, status)
       VALUES ($1, $2, $3, $4, $5, 'confirmed')
       RETURNING id`,
      [req.user.id, parsed.data.serviceId, parsed.data.staffId,
       parsed.data.startAt, service.price_cents]
    );

    // 5. Trừ ví + ghi giao dịch
    await client.query(
      "UPDATE wallets SET balance_cents = balance_cents - $1 WHERE id = $2",
      [service.price_cents, wallet.id]
    );
    await client.query(
      `INSERT INTO wallet_transactions (wallet_id, booking_id, amount_cents, type)
       VALUES ($1, $2, $3, 'payment')`,
      [wallet.id, booking.id, -service.price_cents]
    );

    await client.query("COMMIT");
    res.status(201).json({ bookingId: booking.id, charged: service.price_cents });

  } catch (err) {
    await client.query("ROLLBACK");
    if (err instanceof HttpError) {
      return res.status(err.status).json({ error: err.message });
    }
    logError("POST /bookings/pay-by-wallet", err);
    res.status(500).json({ error: "Lỗi hệ thống" });
  } finally {
    client.release();   // LUÔN trả kết nối về pool
  }
});

Đoạn code này chứa gần như toàn bộ kiến thức backend quan trọng:

Kỹ thuật Vì sao cần
BEGIN / COMMIT / ROLLBACK Đảm bảo năm bước cùng thành công hoặc cùng huỷ
FOR UPDATE Khoá dòng, chặn hai request cùng trừ một ví
Giá lấy từ database Client không thể sửa giá
Tham số hoá query Chống SQL Injection
client.release() trong finally Rò rỉ kết nối sẽ làm sập pool sau vài giờ
HttpError tách khỏi lỗi hệ thống Lỗi nghiệp vụ trả 4xx, lỗi hệ thống trả 5xx

Cảnh báo về FOR UPDATE: nếu quên FOR UPDATE ở bước 1, hai request gửi cùng lúc có thể cùng đọc số dư 10 €, cùng thấy “đủ tiền”, rồi cùng trừ — kết quả ví thành âm. Đây gọi là race condition, và nó chỉ xuất hiện khi có tải thật. Test thủ công một mình sẽ không bao giờ thấy.

5. Năm lỗi thường gặp khi học backend

5.1. Nhồi hết logic vào controller

Controller dài 400 dòng là dấu hiệu chắc chắn của code khó bảo trì. Cách tách chuẩn:

routes.ts      → khai báo đường dẫn, gắn middleware
controller.ts  → đọc request, gọi service, trả response
service.ts     → toàn bộ logic nghiệp vụ
repository.ts  → chỉ nói chuyện với database
schema.ts      → kiểu dữ liệu + validation

Lợi ích không phải là “cho đẹp”. Là để test được logic mà không cần dựng server.

5.2. Không kiểm tra dữ liệu đầu vào

Mọi endpoint nhận dữ liệu từ client đều phải validate. Không có ngoại lệ. Kể cả endpoint “nội bộ” — vì nội bộ hôm nay có thể thành công khai ngày mai.

5.3. Trả về quá nhiều dữ liệu

Đây là lỗi tôi từng mắc. Bảng users có 17 cột, và API trả về nguyên 14 trường — trong đó có hash mật khẩu. Không ai cần hash mật khẩu ở frontend. Cách sửa: dùng hàm lọc riêng.

function publicUser(row) {
  return {
    id: row.id,
    name: row.name,
    phone: row.phone,
    hasPassword: Boolean(row.password),   // chỉ trả TRUE/FALSE
    createdAt: row.created_at,
  };
}

Nguyên tắc: danh sách trắng, không phải danh sách đen. Liệt kê những gì được trả, thay vì xoá những gì không được trả. Thêm cột mới vào database sẽ không tự động lộ ra ngoài.

5.4. Nuốt lỗi

// SAI — không bao giờ làm thế này
try {
  await doSomething();
} catch (e) {
  console.log(e);
}

Nuốt lỗi khiến request treo hoặc trả về dữ liệu sai mà không ai biết. Express 4 không tự bắt lỗi trong hàm async — bạn phải tự xử lý.

5.5. Hardcode bí mật trong mã nguồn

Khoá JWT, mật khẩu database, API key — tất cả phải nằm trong biến môi trường. Và mã nguồn phải từ chối chạy nếu thiếu:

const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
  throw new Error("JWT_SECRET thiếu hoặc quá ngắn — dừng khởi động");
}

Kiểm tra kiểu “fail closed” này quan trọng hơn bạn tưởng. Nếu để mặc định, hệ thống vẫn chạy và trông bình thường — nhưng khoá đã nằm công khai trong repo.

6. Security: những thứ phải làm ngay từ đầu

Bảo mật backend không phải giai đoạn cuối. Nó là một phần của kiến trúc, và làm muộn luôn đắt hơn nhiều lần.

6.1. Sáu điểm tối thiểu

  1. Băm mật khẩu bằng bcrypt hoặc Argon2. Không bao giờ lưu mật khẩu gốc. Không dùng MD5/SHA1.
  2. Kiểm tra quyền ở backend cho mọi endpoint thay đổi dữ liệu. Danh sách trắng, không phải danh sách đen.
  3. Validate mọi đầu vào trước khi chạm tới database.
  4. Rate limit endpoint đăng nhập, gửi OTP, đổi mật khẩu.
  5. Bí mật trong biến môi trường, và fail closed nếu thiếu.
  6. Không trả trường nhạy cảm ra API, dù client có hỏi.

6.2. Kiểm tra quyền: con số biết nói

Cách kiểm tra nhanh nhất một backend có an toàn không, không cần đọc hết code:

# Đếm số endpoint
grep -cE "app\.(get|post|put|patch|delete)\(" server/routes.ts

# Đếm số chỗ kiểm tra quyền
grep -c "requireAdmin" server/routes.ts
grep -c "requireOwner" server/routes.ts

Nếu số endpoint lớn gấp nhiều lần số chỗ kiểm tra quyền, bạn có vấn đề. Trong dự án của tôi tỉ lệ ban đầu là 141 trên 41 — và kết quả đo được là 14 lỗ hổng, 6 trong đó mức nghiêm trọng.

6.3. Đọc thêm

Toàn bộ quá trình tìm và sửa 14 lỗ hổng đó được ghi lại chi tiết, kèm bằng chứng đo thật, trong bài Bảo mật REST API: 14 lỗ hổng tôi tìm thấy trong một hệ thống thật.

7. Performance: ba thứ quyết định tốc độ

90% vấn đề hiệu năng backend nằm ở ba chỗ, theo đúng thứ tự ưu tiên:

7.1. Index database

Không có index, mỗi truy vấn là một lần quét toàn bảng. Với bảng 100 dòng thì không thấy gì; với 1 triệu dòng thì API chậm từ 5 ms lên 5 giây.

-- Truy vấn hay dùng
SELECT * FROM bookings WHERE user_id = $1 ORDER BY start_at DESC;

-- Index tương ứng
CREATE INDEX idx_bookings_user_start ON bookings (user_id, start_at DESC);

Thứ tự cột trong index quan trọng: index trên (user_id, start_at) không giúp được truy vấn chỉ lọc theo start_at.

7.2. N+1 query

Lỗi kinh điển: lấy danh sách rồi lặp để lấy chi tiết. 1 truy vấn biến thành 101 truy vấn.

// SAI — N+1
const bookings = await db.query("SELECT * FROM bookings LIMIT 100");
for (const b of bookings.rows) {
  b.service = (await db.query("SELECT * FROM services WHERE id = $1", [b.service_id])).rows[0];
}

// ĐÚNG — một truy vấn
const { rows } = await db.query(`
  SELECT b.*, s.name AS service_name, s.price_cents
  FROM bookings b
  JOIN services s ON s.id = b.service_id
  ORDER BY b.start_at DESC
  LIMIT 100
`);

7.3. Caching và phân trang

Dữ liệu ít đổi như danh mục dịch vụ nên cache. Dữ liệu dài vô hạn bắt buộc phân trang — luôn đặt LIMIT, kể cả khi hôm nay chỉ có 20 dòng.

app.get("/api/bookings", requireAuth, async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 20, 100);  // chặn tối đa 100
  const offset = Math.max(Number(req.query.offset) || 0, 0);
  const { rows } = await pool.query(
    `SELECT * FROM bookings WHERE user_id = $1
      ORDER BY start_at DESC LIMIT $2 OFFSET $3`,
    [req.user.id, limit, offset]
  );
  res.json({ data: rows, limit, offset });
});

Dòng Math.min(..., 100) rất quan trọng: không có nó, client gọi ?limit=999999 là database sập.

8. Testing: kiểm thử backend thế nào?

Backend khó test hơn frontend vì phần lớn lỗi không hiện ra trên màn hình. Ba tầng test cần thiết:

Tầng Kiểm tra gì Chạy khi nào
Unit test Hàm thuần: tính giá, tính lương, format ngày Mỗi lần lưu file
Integration test Endpoint + database thật Trước mỗi lần deploy
Security test Gọi API không token, token sai, sai quyền Trước mỗi lần deploy

Ví dụ một integration test thật cho endpoint ví:

test("nạp tiền vào ví mà không có token phải trả 401", async () => {
  const res = await fetch(`${BASE}/api/users/${someUserId}/wallet`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ amount: 100 }),
  });
  expect(res.status).toBe(401);
});

test("nạp tiền có token admin thì số dư tăng đúng", async () => {
  const before = await getBalance(someUserId);

  const res = await fetch(`${BASE}/api/users/${someUserId}/wallet`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({ amount: 100 }),
  });

  expect(res.status).toBe(200);
  const after = await getBalance(someUserId);
  expect(after).toBe(before + 100);
});

Nguyên tắc quan trọng: test bảo mật phải chạy trên môi trường local, không chạy trên production. Test dạng này thay đổi dữ liệu thật.

9. Deployment: đưa backend lên chạy thật

9.1. Biến môi trường

NODE_ENV=production
PORT=5103
DATABASE_URL=postgres://user:pass@host:5432/dbname
JWT_SECRET=<chuỗi ngẫu nhiên tối thiểu 32 ký tự>

9.2. Health check — thứ đầu tiên phải có

app.get("/api/health", async (req, res) => {
  try {
    await pool.query("SELECT 1");
    res.json({ status: "ok", db: "connected", uptime: process.uptime() });
  } catch (err) {
    res.status(503).json({ status: "error", db: "disconnected" });
  }
});

Endpoint này trả về cả tình trạng database, không chỉ “server còn sống”. Một backend trả 200 OK trong khi database đã chết là backend đang nói dối.

9.3. Quy trình deploy an toàn

npm run build            # 1. Build
# 2. Copy artifact lên server
# 3. Dừng tiến trình cũ
# 4. Chờ 5 giây cho cổng được giải phóng
# 5. Khởi động bản mới
# 6. Chờ 20 giây cho app khởi động
curl -f https://api.example.com/api/health   # 7. Kiểm tra

Bước 4 và bước 7 là hai bước hay bị bỏ nhất. Bỏ bước 4 thì bản mới không bind được cổng. Bỏ bước 7 thì bạn không biết deploy thành công hay thất bại.

10. Checklist 12 điểm

Dùng danh sách này để rà lại backend đang viết:

[ ] 1.  Mọi endpoint thay đổi dữ liệu đều có kiểm tra quyền
[ ] 2.  Mọi đầu vào từ client đều được validate
[ ] 3.  Mật khẩu được băm bằng bcrypt hoặc Argon2
[ ] 4.  Không có bí mật nào trong mã nguồn
[ ] 5.  Ứng dụng từ chối khởi động nếu thiếu biến môi trường
[ ] 6.  Response không chứa trường nhạy cảm
[ ] 7.  Mọi truy vấn dùng tham số, không nối chuỗi
[ ] 8.  Các thao tác nhiều bước nằm trong transaction
[ ] 9.  Có index cho các cột dùng để lọc và sắp xếp
[ ] 10. Mọi danh sách đều có phân trang
[ ] 11. Lỗi async được bắt và ghi log, không bị nuốt
[ ] 12. Có endpoint health check và log lỗi tập trung

Câu hỏi thường gặp

Học backend nên bắt đầu từ ngôn ngữ nào?

Nếu bạn đã biết JavaScript, hãy học Node.js — bạn dùng lại được kiến thức frontend và tập trung vào khái niệm backend thay vì học ngôn ngữ mới. Nếu bạn muốn đi theo hướng dữ liệu, AI hoặc làm việc với doanh nghiệp lớn, Python với FastAPI là lựa chọn tốt. Điều quan trọng hơn ngôn ngữ: bạn phải hiểu HTTP, database và bảo mật, vì ba thứ đó giống nhau ở mọi ngôn ngữ.

Backend có cần học frontend không?

Không bắt buộc, nhưng nên biết cơ bản. Bạn sẽ nói chuyện với frontend mỗi ngày, và hiểu họ cần gì giúp bạn thiết kế API tốt hơn nhiều. Ít nhất hãy biết cách gọi API bằng fetch và đọc JSON.

Backend và API có phải là một?

Không. API là giao diện của backend — cách thế giới bên ngoài nói chuyện với nó. Backend là toàn bộ phần mềm phía sau, bao gồm cả những thứ không lộ ra qua API như job chạy nền, gửi email, tính lương, sao lưu.

Có nhất thiết phải dùng framework không?

Không, nhưng nên dùng. Framework xử lý sẵn định tuyến, middleware, phân tích body, xử lý lỗi. Tự viết lại những thứ này không dạy bạn thêm được gì, chỉ tốn thời gian và tạo ra lỗi bảo mật mới.

Bao lâu thì học được backend?

Biết viết một API CRUD: khoảng 2–4 tuần. Viết được backend chạy thật, có phân quyền, transaction, xử lý lỗi và deploy được: khoảng 3–6 tháng luyện đều. Nhanh hơn nhiều nếu bạn học trên một dự án thật thay vì làm bài tập rời rạc.

Backend có cần biết Docker không?

Nên biết ở mức cơ bản. Docker giúp môi trường phát triển giống môi trường chạy thật, giải quyết câu “máy tôi chạy được mà máy chủ không chạy được”. Bạn không cần thành chuyên gia Kubernetes để làm backend.

Làm sao biết backend của mình có lỗ hổng?

Đừng đoán — hãy đo. Viết bộ test tự động gọi API bằng nhiều cách khác nhau: không có token, token sai, token của người khác, token hết hạn. Trong dự án của tôi, cách này tìm ra 14 lỗ hổng mà đọc code bằng mắt đã bỏ sót nhiều lần.

Nên học lý thuyết trước hay làm dự án trước?

Làm dự án trước, nhưng đủ nhỏ để không bị ngợp. Lý thuyết chỉ có nghĩa khi bạn đã gặp vấn đề mà nó giải quyết. Học transaction sau khi bạn đã gặp lỗi trừ tiền hai lần sẽ nhớ mãi; học trước thì ba tuần sau quên sạch.

Kết luận

Backend là phần mềm chạy trên máy chủ, chịu trách nhiệm cho mọi thứ quan trọng: logic nghiệp vụ, dữ liệu, quyền hạn và bảo mật. Người dùng không bao giờ nhìn thấy nó, nhưng mọi thứ họ tin tưởng đều do nó quyết định.

Nếu bạn đang bắt đầu, hãy nhớ bốn điều:

  1. Không tin client. Mọi kiểm tra ở frontend chỉ là trải nghiệm, không phải bảo mật.
  2. Validate ở cửa vào. Dữ liệu bẩn vào được database thì sửa rất tốn kém.
  3. Thao tác nhiều bước phải nằm trong transaction. Không có ngoại lệ khi có liên quan tới tiền.
  4. Đo, đừng đoán. Viết test, đếm endpoint, đo thời gian phản hồi. Cảm giác “chắc là ổn” đã làm tôi bỏ sót 14 lỗ hổng.

Phần còn lại là luyện tập trên dự án thật. Lý thuyết backend không khó; cái khó là giữ được sự tỉ mỉ khi hệ thống đã chạy và không ai còn nhớ vì sao đoạn code kia được viết như vậy.


Bài tiếp theo trong loạt: REST API là gì? Hướng dẫn xây dựng REST API từ A-Z — đi sâu vào cách thiết kế endpoint, mã trạng thái, versioning và tài liệu API.

Bài liên quan:

Học backend bằng cách làm dự án thật

Đọc về transaction hay middleware sẽ không giúp bạn nhớ lâu. Cái giúp nhớ là tự tay làm hỏng một lần rồi sửa.

Tại Hướng Nghiệp Dữ Liệu, các khoá học backend được dạy trực tiếp trên dự án đang chạy — cùng loại hệ thống mà bạn vừa đọc ở trên. Bạn sẽ tự viết API, tự gặp lỗi trừ tiền hai lần, tự tìm ra lỗ hổng phân quyền, và tự sửa.

Xem thêm các bài khác trong loạt Backend tại huongnghiepdulieu.com.

Đặng Trí Thanh

Đặng Trí Thanh

Giám đốc Công nghệ · DNT Digital · Giảng viên HNDL Hướng Nghiệp Dữ Liệu
1.389 Bài viết
15.4k Người theo dõi
120k+ Lượt đọc

Đặng Trí Thanh — Founder & CTO · Hướng Nghiệp Dữ Liệu - DNT Digital. Chuyên đào tạo và triển khai thực chiến Python, MT5 và hệ thống bot auto trading / IB cho học viên và doanh nghiệp.

Đội ngũ hỗ trợ

Đặng Trí Thanh
Đặng Trí Thanh
Giám đốc Công nghệ DNT Digital
Zalo 0934145100
Mộng Cầm
Mộng Cầm
Hỗ trợ khách hàng · Huấn luyện viên
Zalo 0927909257
Khánh Linh
Khánh Linh
Hỗ trợ khách hàng · Huấn luyện viên
Zalo 0927909582