Base.vn Prep

2026-05-19 · Middle-level

BACKEND

Backend Deep Dive

9 topic. Stack focus: NestJS + Prisma + Postgres + Redis + BullMQ. Bonus: Rust case study từ sales_agent.

1 · Client-Server Architecture

HTTP lifecycle: DNS → TCP → TLS → HTTP → response → close/keep-alive

REST

CRUD chuẩn, HTTP cache theo URL. Default pick.

GraphQL

Client chọn field. Trade: cache khó, dễ N+1.

WebSocket / SSE

Bidirectional / server push. Chat, presence, live.

Stateless: session vào Redis hoặc JWT → scale ngang dễ.

Pitfall: WS cho mọi real-time → tốn socket. Polling 30s đủ cho notification. Quên CORS + idempotency POST.

2 · Database Design (PostgreSQL)

Pick: SQL khi schema ổn định + JOIN/transaction. NoSQL khi schema linh hoạt + scale write cao.

-- Hot query: WHERE user_id = ? ORDER BY created_at DESC
CREATE INDEX idx_tasks_user_created ON tasks(user_id, created_at DESC);

-- Composite index thứ tự: equality TRƯỚC, range SAU
-- Bad:  ON tasks(created_at, user_id) — dùng user_id miss index
-- Good: ON tasks(user_id, created_at)

Normalization (3NF)

Không trùng data. Write nhanh, read cần JOIN. Default OLTP.

Denormalization

Duplicate cho read speed. Khi read » write (analytics).

Transaction: Multi-table write → BẮT BUỘC transaction. Prisma: prisma.$transaction([...])

Pitfall: Index quá nhiều → chậm write. Soft delete quên WHERE deleted_at IS NULL → leak. UUID v4 PK → fragment index → ULID/UUIDv7.

3 · Caching Strategies (Redis)

CDN

Static, edge. Cloudflare/Vercel.

App (Redis)

Session, hot query, rate limit, queue.

DB query cache

Prepared statement, materialized view.

// Cache-aside — default
async function getUser(id: string) {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);
  const user = await db.user.findUnique({ where: { id } });
  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
  return user;
}

// Invalidate khi update
async function updateUser(id: string, data) {
  const user = await db.user.update({ where: { id }, data });
  await redis.del(`user:${id}`); // KHÔNG set lại — race condition
  return user;
}

Strategy: TTL (simple, eventual) · Event-based (chính xác, phức tạp) · Write-through (consistent, slow write).

Pitfall: Cache stampede — 1000 req cùng miss → đánh DB. Fix: lock (single-flight) + stale-while-revalidate. Cache key thiếu tenant_id → leak cross-tenant.

4 · Load Balancing & Horizontal Scaling

   Internet
      │
      ▼
  [Load Balancer]   ── Round-robin / Least-conn / IP-hash
   │   │   │
   ▼   ▼   ▼
  app app app       ── Stateless instances (Docker/K8s)
   │   │   │
   └───┼───┘
       ▼
  [Redis]  [Postgres replica · read]  [Postgres primary · write]

Read replica: Tách read sang replica, write vào primary. Read throughput 3-5×. Trade: replication lag.

Pitfall: Sticky session → scale khó. Dùng Redis session. Quên health check → LB route vào instance chết.

5 · Async Processing (BullMQ)

Nguyên tắc: Đừng block HTTP. User click "Send 10k email" → trả 202 Accepted, push job.

const emailQueue = new Queue('email', { connection: redis });

@Post('campaigns/:id/send')
async sendCampaign(@Param('id') id: string) {
  await emailQueue.add('send-batch', { campaignId: id }, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 }, // 5s, 25s, 125s
  });
  return { status: 'queued' }; // 202
}

new Worker('email', async (job) => {
  await sendEmailBatch(job.data.campaignId);
}, { connection: redis, concurrency: 10 });

Pitfall: Job không idempotent → retry tạo duplicate. Dedup key. Worker crash giữa chừng → state machine cho long job.

6 · Multi-tenancy Patterns ★ critical Base.vn

A · Shared DB / Shared Schema (column-based)

Mỗi table có tenant_id. Query filter.

✓ Cheap, scale dễ   ✗ Leak risk nếu quên WHERE

B · Shared DB / Separate Schema

Mỗi tenant 1 PG schema. SET search_path.

✓ Isolation tốt hơn   ✗ Migration n × schema

C · Separate DB per tenant

Mỗi tenant 1 DB. Enterprise, regulated.

✓ Strict isolation, custom backup   ✗ Cost cao

Enforce: Middleware inject tenantId từ JWT. Postgres RLS làm safety net.

Pitfall: Admin query quên scope → leak. JOIN qua table khác tenant. Cache key thiếu tenant_id.

7 · REST API + JWT Auth

[Login]   POST /auth/login → access (15m, body) + refresh (7d, httpOnly cookie)
[Auth]    GET /tasks  Authorization: Bearer <access> → verify sig + exp
[Refresh] 401 → POST /auth/refresh (cookie auto-sent) → rotate

Access token

Short (5-15m). Memory (NOT localStorage).

Refresh token

Long (7-30d). httpOnly + Secure + SameSite=Lax cookie. Rotate on use.

REST conventions: POST tạo · GET đọc · PUT replace · PATCH partial · DELETE. Status: 200/201/204 · 400 bad · 401 unauth · 403 forbidden · 404 not found · 409 conflict · 422 validation · 5xx.

Pitfall: JWT lưu localStorage → XSS đọc được. Không refresh rotation → hijack forever. Stack trace lộ secret trong error.

8 · Prisma ORM

// Transaction — atomic multi-table
await prisma.$transaction(async (tx) => {
  const order = await tx.order.create({ data: { userId, total } });
  await tx.stock.update({
    where: { productId },
    data: { quantity: { decrement: 1 } },
  });
  await tx.invoice.create({ data: { orderId: order.id } });
});

// Avoid N+1 — include / select
const tasks = await prisma.task.findMany({
  where: { tenantId },
  include: { assignee: true, comments: { take: 3 } },
}); // 1 query JOIN, không phải N

Pitfall: Quên include → N+1. Transaction dài → lock table. Migration conflict khi merge branch. Schema thay đổi nhưng quên regen client.

9 · 🦀 Backend in Rust — sales_agent

Real codebase

Talking point: "patterns transfer across languages". Module structure (handlers/repository/models) chạy được trên NestJS, FastAPI, Axum. Stack: Axum + sqlx + Postgres + Redis + JWT.

▶ Module structure (≡ NestJS feature module)

backend/src/
├── auth/        { mod, models, handlers, jwt, middleware }
├── drafts/      { mod, models, handlers, repository, cleanup }
├── leads/       { mod, models, handlers, repository }
├── error.rs     ← unified AppError enum
├── db.rs        ← PgPool init
└── routes.rs    ← wire handlers to paths

→ Map: auth/ = AuthModule · repository.rs = AuthRepository · handlers.rs = AuthController.

▶ JWT extractor (≡ NestJS Guard)

// auth/middleware.rs
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthUser
where S: Send + Sync + AsRef<AppState> {
    type Rejection = AppError;
    async fn from_request_parts(parts: &mut Parts, state: &S)
        -> Result<Self, Self::Rejection> {
        let header = parts.headers.get("Authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or_else(|| AppError::Unauthorized("Missing auth header".into()))?;
        let token = header.strip_prefix("Bearer ")
            .ok_or_else(|| AppError::Unauthorized("Invalid format".into()))?;
        let claims = decode_token(token, &state.as_ref().jwt_secret)?;
        Ok(AuthUser { user_id: claims.sub })
    }
}

→ Extractor Axum ≡ Guard+decorator NestJS ≡ Depends() FastAPI. Inject authed user, fail-fast nếu invalid.

▶ Repository + sqlx

// drafts/repository.rs
pub async fn create(pool: &PgPool, user_id: Uuid, source: &DraftSource, ...)
    -> Result<DraftContact, AppError>
{
    let draft = sqlx::query_as::<_, DraftContact>(r#"
        INSERT INTO draft_contacts (user_id, source, extracted_data,
            confidence_data, raw_input, telegram_chat_id, expires_at)
        VALUES ($1, $2, $3, $4, $5, $6, NOW() + INTERVAL '24 hours')
        RETURNING *
    "#)
    .bind(user_id).bind(source).bind(extracted_data)
    .fetch_one(pool).await?;
    Ok(draft)
}

→ Y hệt Prisma repository. Compile-time SQL check (sqlx macro) ≡ TS types từ Prisma. Same benefit, khác syntax.

▶ Async cleanup (không cần Redis queue)

// drafts/cleanup.rs — xóa draft hết hạn mỗi giờ
pub fn start_cleanup_task(pool: PgPool) {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_secs(3600));
        loop {
            interval.tick().await;
            match repository::delete_expired(&pool).await {
                Ok(count) if count > 0 => tracing::info!("Deleted {count} drafts"),
                Err(e) => tracing::error!("Cleanup error: {e}"),
                _ => {}
            }
        }
    });
}

→ Spawn cho periodic cleanup, metric. BullMQ khi cần retry, scale worker, observability. Đừng over-engineer.

▶ Unified error → HTTP mapping

// error.rs
pub enum AppError {
    BadRequest(String),         // 400
    Unauthorized(String),       // 401
    NotFound(String),           // 404
    Conflict(String),           // 409
    ValidationError(String),    // 422
    RateLimited(String),        // 429
    InternalError(anyhow::Error), // 500 — hide details
}
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code, msg) = match &self { /* enum → tuple */ };
        (status, Json(json!({ "error": { "code": code, "message": msg }}))).into_response()
    }
}

→ NestJS có HttpException, FastAPI HTTPException. Rust dùng thiserror derive. Handler chỉ ? propagate.

▶ Migration với ENUM + index

-- migrations/002_create_contacts.sql
DO $$ BEGIN
    CREATE TYPE contact_source AS ENUM (
        'card_scan', 'text_input', 'telegram',
        'linkedin_search', 'linkedin_connection', 'manual'
    );
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

CREATE TABLE IF NOT EXISTS contacts (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source       contact_source NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_contacts_full_name_company
  ON contacts (full_name, company_name);

DO $$ ... duplicate_object = idempotent migration. ENUM rẻ hơn varchar + check constraint.

💡 Pitch sales_agent trong interview

"Em build 1 backend Rust với Axum + sqlx — hệ thống scan name card, parse contact, push lên HubSpot. Pattern giống NestJS: module-per-feature, repository tách khỏi handler, error enum map sang HTTP status qua trait. Rust ép em handle Option/Result tường minh, ít bug null. Em pick Rust vì có job xử ảnh + AI nặng — tokio handle concurrent tốt với footprint nhỏ."

← Phase 3 Next: Frontend →
🔥 BE DEEP

Phase 3 — Backend Deep Dive

BE Async (Event loop, Promise, Idempotency, DB race, Workers, Streams) + BE Deep (REST API, JWT, Prisma N+1, Transactions, Multi-tenancy)

🔁 BE Async — 6 Topics

1. Event Loop · "Node single-thread mà concurrent?"

JS execution single-thread, I/O delegate libuv thread pool (default 4 threads). Event loop coordinate callbacks.

// Trace test
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
// (sync → microtask Promise → macrotask setTimeout)

2. Promise Combinators

MethodFail behaviorUse case
allFirst fail → throwAll must succeed
allSettledNever throwsCollect partial
raceFirst settle winsImplement timeout
anyAll fail → AggregateErrorTry multiple, take fastest success

3. Idempotency · POST /pay 2 lần fix sao

async function charge(idempotencyKey: string, amount: number) {
  const existing = await db.query(
    'SELECT result FROM idempotency_log WHERE key = $1', [idempotencyKey]
  );
  if (existing.rows.length > 0) return existing.rows[0].result;  // cached
  const result = await stripe.charge({ amount });
  await db.query(
    'INSERT INTO idempotency_log (key, result) VALUES ($1, $2) ON CONFLICT DO NOTHING',
    [idempotencyKey, result]
  );
  return result;
}

4. DB Race Conditions · 3 fixes

❌ Race condition KHÔNG fix:

sequenceDiagram autonumber participant T1 as Transaction 1 participant T2 as Transaction 2 participant DB as users
balance=100 T1->>DB: SELECT balance → 100 T2->>DB: SELECT balance → 100 T1->>T1: calc: 100 - 100 = 0 T2->>T2: calc: 100 - 100 = 0 T1->>DB: UPDATE balance = 0 T2->>DB: UPDATE balance = 0 Note over DB: 💀 User rút $200 từ tài khoản $100
(lost update)
-- Solution 1: Atomic UPDATE (simplest)
UPDATE users SET balance = balance - $1
WHERE id = $2 AND balance >= $1;

-- Solution 2: SELECT FOR UPDATE (row lock)
BEGIN;
SELECT balance FROM users WHERE id = $1 FOR UPDATE;
-- calculate ...
UPDATE users SET balance = $1 WHERE id = $2;
COMMIT;

-- Solution 3: Optimistic locking with version
UPDATE users SET balance = $1, version = version + 1
WHERE id = $2 AND version = $3;
-- check rowsAffected → 0 means concurrent update → retry/409

5. Worker Threads · CPU-bound

Memo: I/O = native async (event loop). CPU = worker thread.

Use caseWorker?
Image resize
JSON parse 100MB
Crypto hashing
HTTP request❌ (I/O native)
DB query❌ (I/O)

6. Streams + Backpressure · 10GB CSV

import { pipeline } from 'node:stream/promises';
await pipeline(
  createReadStream('huge.csv'),
  transformStream,
  createWriteStream('out.csv')
);
// Memory: vài KB thay vì 10GB. pipeline() handle backpressure auto.

⚙️ Backend Deep — REST / JWT / Prisma / Multi-tenancy

Hiểu WHY trước HOW. Mỗi card có pitfalls + interview phrase.

1. REST API Design · HTTP verbs + status codes + idempotency

Why care: Interview hay hỏi "PUT vs PATCH?" hoặc "tại sao 201 không 200?". Trả lời đúng thể hiện thinking về idempotency + semantic clarity.

HTTP Verb Cheat:

VerbPurposeIdempotent?Safe?
GETRead✅ (no side effect)
POSTCreate (server gen ID)
PUTReplace toàn bộ resource (client biết ID)
PATCHUpdate partial fields⚠️ Depends (mostly yes)
DELETERemove✅ (delete twice = same state)

Status Code Cheat:

CodeMeaningWhen dùng
200 OKSuccess + bodyGET, PUT/PATCH return updated
201 CreatedResource createdPOST → trả new entity + Location header
204 No ContentSuccess, no bodyDELETE thành công
400 Bad RequestClient validation failZod schema fail, missing field
401 UnauthorizedChưa login / token invalidMissing/expired JWT
403 ForbiddenLogin rồi nhưng không có quyềnRBAC fail (user ≠ owner)
404 Not FoundResource không tồn tạiGET /users/999 không thấy
409 ConflictState conflictEmail duplicate, optimistic lock fail
422 UnprocessableSyntax OK, semantic failDate past in future-only field (optional, thường gộp vào 400)
429 Too ManyRate limitAPI throttle
500 Server ErrorUnhandled exceptionLogic crash. KHÔNG leak stack trace ra client.

PUT vs PATCH — phrase nhớ:

// PUT — Replace toàn bộ. Field nào không gửi → set null/default.
PUT /users/42  { name: 'Alice', email: 'a@x.com', age: 30 }
// Server: ghi đè toàn bộ row.

// PATCH — Update các field gửi lên. Field không gửi → giữ nguyên.
PATCH /users/42  { age: 31 }
// Server: chỉ update age.

Idempotency tại tầng HTTP (khác idempotency tầng business):

⚠️ Pitfalls:

2. JWT Lifecycle · Access + Refresh + Rotation + Storage

Why 2 token? Access token short-lived (15min) → nếu leak thì attacker hết quyền nhanh. Refresh token long-lived (7-30 ngày) → renew access mà không bắt user re-login.

Flow chuẩn (4 bước):

sequenceDiagram autonumber actor U as User participant C as Client participant S as Server participant DB as Refresh
Token Store Note over U,DB: 1️⃣ LOGIN U->>C: email + password C->>S: POST /auth/login S->>DB: store refresh hash S-->>C: { accessToken (15m), refreshToken (7d) } Note over U,DB: 2️⃣ CALL API C->>S: GET /api/tasks
Authorization: Bearer access S-->>C: 200 data Note over U,DB: 3️⃣ ACCESS EXPIRED → renew C->>S: GET /api/tasks (access expired) S-->>C: 401 Unauthorized C->>S: POST /auth/refresh { refreshToken } S->>DB: validate + invalidate old refresh S->>DB: store new refresh (ROTATION) S-->>C: { new accessToken, new refreshToken } C->>S: retry GET /api/tasks S-->>C: 200 data Note over U,DB: 4️⃣ LOGOUT C->>S: POST /auth/logout S->>DB: revoke refresh (blacklist) S-->>C: 204

Refresh Token Rotation — tại sao MUST:

Storage trade-offs:

StorageXSS safe?CSRF safe?Notes
localStorage❌ JS đọc đượcNếu site có XSS → token bị steal
Cookie httpOnly✅ JS không đọc❌ Cần CSRF tokenSet Secure + SameSite=Lax/Strict
Memory (in-memory)✅ bestMất khi reload trang → cần refresh flow

Standard 2026: Access trong memory, Refresh trong cookie httpOnly + Secure + SameSite=Strict. Best of both worlds.

JWT structure (3 phần ngăn bằng dấu .):

// header.payload.signature
// header: { alg: 'HS256', typ: 'JWT' }
// payload: { sub: userId, role: 'admin', exp: 1715000000 }
// signature: HMAC(base64(header) + '.' + base64(payload), SECRET)

// ⚠️ Payload CHỈ base64-encoded, KHÔNG mã hoá!
// → KHÔNG bao giờ để password, PII, credit card trong JWT.

⚠️ Pitfalls:

2b. JWT Storage Deep · "In-memory" nghĩa là gì?

Why care: Khi nói "Access token lưu in-memory", chữ memory KHÔNG phải RAM nói chung, cũng KHÔNG phải localStorage. Đây là điểm hay bị hỏi đào sâu.

"Memory" = JavaScript runtime variable:

Token lưu vào 1 biến JS (module-level, React Context, hoặc Zustand store). Biến này sống trong JS engine của tab browser → mất khi reload/đóng tab. KHÔNG persistent.

StorageReload còn?JS đọc?XSS steal?
Memory (biến JS)✅ chỉ trong app code⚠️ Khó hơn
localStorage✅ Dễ
sessionStorage✅ (cùng tab)✅ Dễ
Cookie httpOnly

Tại sao "mất khi reload" là FEATURE, không phải bug:

Implementation — Module variable (simplest):

// lib/auth-store.ts
let accessToken: string | null = null;  // ← biến JS trong module scope

export function setAccessToken(t: string) { accessToken = t; }
export function getAccessToken() { return accessToken; }
export function clearAccessToken() { accessToken = null; }

// lib/api-client.ts — fetch interceptor đọc memory
api.interceptors.request.use(config => {
  const token = getAccessToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

Bootstrap khi reload page (BẮT BUỘC):

// app/bootstrap.ts — gọi 1 lần khi app khởi động
async function bootstrap() {
  try {
    // Cookie httpOnly tự gửi (browser handle), JS không thấy
    const { accessToken } = await api.post('/auth/refresh');
    setAccessToken(accessToken);   // ← lưu vào memory
  } catch {
    redirectToLogin();   // refresh token cũng hết hạn
  }
}
// React: useEffect(() => { bootstrap(); }, []) trong root

Lifecycle sơ đồ (sequence):

sequenceDiagram autonumber actor U as User participant C as Client (JS) participant M as Memory
(JS variable) participant Ck as Cookie
httpOnly participant S as Server U->>C: Click Login C->>S: POST /auth/login S-->>C: { accessToken } + Set-Cookie refreshToken C->>M: setAccessToken(t) S->>Ck: Browser auto-store Note over U,S: ...user dùng app, fetch interceptor đọc memory... U->>C: API request C->>M: getAccessToken() M-->>C: Bearer token C->>S: GET /api/data + Authorization S-->>C: 200 data Note over C,M: 🔄 User press F5 (reload) Note over M: 💥 Memory cleared!
accessToken = undefined C->>S: Bootstrap: POST /auth/refresh Ck->>S: Cookie auto-sent S-->>C: { accessToken } (mới) C->>M: setAccessToken(t) ← restore Note over U: User KHÔNG bị logout ✅

⚠️ Pitfalls:

Interview phrase: "Access token em lưu vào module variable trong JS — gọi là 'in-memory'. Mỗi reload mất nhưng app có bootstrap flow gọi /auth/refresh với refresh token trong cookie httpOnly để lấy lại. XSS khó steal được vì biến chỉ sống trong code app, không persistent."

2c. Idempotency-Key Deep · Chống "trừ tiền 2 lần"

Why care: POST không idempotent → network timeout + retry = duplicate charge. Stripe/PayPal/Square dùng pattern này.

❌ Vấn đề KHI KHÔNG có Idempotency-Key:

sequenceDiagram autonumber actor U as User participant C as Client participant S as Server participant St as Stripe U->>C: Click "Pay" C->>S: POST /pay { amount: 100 } S->>St: charge $100 St-->>S: ✅ charged S-->>C: 200 OK Note over C,S: 🌧️ Network timeout!
Response KHÔNG tới client C->>C: Tưởng fail → auto-retry C->>S: POST /pay { amount: 100 } (lần 2) S->>St: charge $100 (LẦN 2!) St-->>S: ✅ charged S-->>C: 200 OK Note over U: 💀 User bị trừ $200 thay vì $100

✅ Fix bằng Idempotency-Key:

sequenceDiagram autonumber actor U as User participant C as Client participant S as Server participant DB as DB
idempotency_log participant St as Stripe U->>C: Click "Pay" C->>C: key = crypto.randomUUID()
"a3f9-bc12-..." C->>S: POST /pay
Idempotency-Key: a3f9 S->>DB: SELECT WHERE key='a3f9' DB-->>S: empty (chưa thấy) S->>St: charge $100 St-->>S: ✅ charged S->>DB: INSERT (key='a3f9', result) S-->>C: 200 result Note over C,S: 🌧️ Network timeout!
Response không tới C->>S: RETRY POST /pay
Idempotency-Key: a3f9 (CÙNG key) S->>DB: SELECT WHERE key='a3f9' DB-->>S: cached result ✅ S-->>C: 200 result (KHÔNG charge lại) Note over U: ✅ User chỉ bị trừ $100

Cơ chế:

Client sinh 1 UUID mỗi intent (mỗi lần user bấm Pay = 1 key, retry vẫn dùng key đó). Server cache (key → result), retry gặp key cũ trả result cũ, không xử lý lại.

Client gửi key:

// Generate khi user click button, KHÔNG generate khi retry
function handlePayClick() {
  const key = crypto.randomUUID();  // ← sinh 1 lần / 1 intent
  return retryWithBackoff(() => fetch('/pay', {
    method: 'POST',
    headers: { 'Idempotency-Key': key },   // ← cùng key cho mọi retry
    body: JSON.stringify({ amount: 100 })
  }));
}

Server xử lý (KISS version):

async function pay(req, res) {
  const key = req.header('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });

  // 1. Check cache trước
  const cached = await db.findIdempotencyLog(key);
  if (cached) return res.json(cached.result);  // ← trả result cũ, KHÔNG charge

  // 2. Lần đầu → thực hiện
  const result = await stripe.charge(req.body.amount);

  // 3. Lưu để retry trả result cũ
  await db.insertIdempotencyLog(key, result);
  return res.json(result);
}

Schema bảng log:

CREATE TABLE idempotency_log (
  key          TEXT PRIMARY KEY,    -- UUID client gửi
  result       JSONB NOT NULL,      -- response cached
  status_code  INT NOT NULL DEFAULT 200,
  request_hash TEXT,                -- hash body chống reuse key cho request khác
  created_at   TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_idem_created ON idempotency_log(created_at);
-- TTL cleanup job xoá row > 24h (Stripe pattern)

Edge case 1 · Race condition (2 request cùng lúc):

Lần 2 đến trong khi lần 1 chưa kịp INSERT log → cả 2 đều charge. Fix: claim key trước bằng INSERT ON CONFLICT.

sequenceDiagram autonumber participant R1 as Request 1 participant R2 as Request 2
(arrives concurrently) participant DB as DB participant St as Stripe R1->>DB: INSERT key='a3f9' status='PENDING'
ON CONFLICT DO NOTHING DB-->>R1: ✅ claimed (1 row) R2->>DB: INSERT key='a3f9' status='PENDING'
ON CONFLICT DO NOTHING DB-->>R2: ❌ 0 rows (R1 đã claim) Note over R2: R2 wait + poll for R1's result R1->>St: charge $100 St-->>R1: ✅ R1->>DB: UPDATE result, status='OK' R2->>DB: SELECT result WHERE key='a3f9' DB-->>R2: cached result Note over R1,R2: ✅ Chỉ 1 lần charge dù 2 request đến cùng lúc
const claim = await db.query(
  `INSERT INTO idempotency_log (key, status_code) VALUES ($1, 'PENDING')
   ON CONFLICT (key) DO NOTHING RETURNING *`,
  [key]
);
if (claim.rows.length === 0) {
  return await waitOrReturnCached(key);  // request khác đang xử lý
}
const result = await stripe.charge(amount);
await db.query('UPDATE idempotency_log SET result = $1, status_code = 200 WHERE key = $2', [result, key]);

Edge case 2 · Cùng key, body KHÁC:

Client buggy gửi key "a3f9" lần 1 amount=100, lần 2 amount=200. Stripe pattern: hash body → so với request_hash đã lưu. Khác → trả 422 Unprocessable "Idempotency-Key reused with different request body" để bảo vệ user.

⚠️ Pitfalls:

Interview phrase: "Idempotency-Key là header client tự sinh (UUID v4), gửi cùng key cho mọi retry của 1 intent. Server lưu (key → result) trong table TTL ~24h. Retry gặp key cũ trả lại result cũ, không xử lý lại. Em dùng pattern này cho operation có side-effect tốn kém như payment hoặc gửi email."

3. Prisma N+1 Problem · Fix bằng include + DataLoader

Why care: N+1 là perf bug phổ biến nhất với ORM. Page list 100 user × mỗi user 1 query lấy posts = 101 query. Latency tăng tuyến tính → app chậm khi data scale.

❌ N+1 anti-pattern (visualization):

flowchart LR A[App] -->|1 query| DB[(DB)] DB -->|100 users| A A -->|query for user 1| DB A -->|query for user 2| DB A -->|query for user 3| DB A -->|...| DB A -->|query for user 100| DB DB -->|posts of each| A style A fill:#1e293b,stroke:#ef4444 style DB fill:#0f172a,stroke:#ef4444

→ 1 query users + 100 query posts = 101 query. Latency O(n).

const users = await prisma.user.findMany();          // 1 query
for (const user of users) {
  user.posts = await prisma.post.findMany({           // N query (1 mỗi user)
    where: { authorId: user.id }
  });
}
// Total: 1 + N query. 100 user → 101 query.

✅ Fix với include (visualization):

flowchart LR A2[App] -->|1 query với JOIN| DB2[(DB)] DB2 -->|100 users + posts
nested| A2 style A2 fill:#1e293b,stroke:#10b981 style DB2 fill:#0f172a,stroke:#10b981

1 query duy nhất. Latency O(1).

✅ Fix 1: include (Prisma tự JOIN):

const users = await prisma.user.findMany({
  include: { posts: true }   // Prisma tự gen 1 query SQL có JOIN
});
// 1 query duy nhất. Done.

✅ Fix 2: select (chỉ field cần):

// Cẩn thận: include = lấy TẤT CẢ field của relation
// → tốn bandwidth nếu post có column lớn (content blob)
const users = await prisma.user.findMany({
  select: {
    id: true, name: true,
    posts: { select: { id: true, title: true } }  // chỉ lấy id + title
  }
});

Khi nào cần DataLoader pattern?

DataLoader = batch các call lại trong cùng 1 tick rồi gọi 1 lần. Pattern này sinh ra cho GraphQL (resolver chạy độc lập cho từng field → không biết về nhau → dễ N+1).

// GraphQL resolver — không có include sẵn
const userLoader = new DataLoader(async (userIds: number[]) => {
  // Batch: tất cả userIds dồn vào 1 query
  const users = await prisma.user.findMany({
    where: { id: { in: userIds } }
  });
  // Trả về theo đúng thứ tự input → DataLoader map lại
  return userIds.map(id => users.find(u => u.id === id));
});

// Resolver gọi loader, KHÔNG gọi DB trực tiếp
postResolver = {
  author: (post) => userLoader.load(post.authorId)
};
// 100 post.author trong 1 tick → 1 query DB.

⚠️ Pitfalls:

Interview phrase: "N+1 em phát hiện qua Prisma query log. Fix bằng include hoặc select. Nếu là GraphQL thì pattern DataLoader để batch resolver calls."

4. Prisma Transactions · Batch vs Interactive + Isolation

Why transaction: Nhiều DB write phải all-succeed-or-all-fail (atomicity). VD: chuyển tiền — trừ A, cộng B. Nếu cộng B fail mà A đã trừ → tiền biến mất.

Atomicity flow (success vs rollback):

flowchart TD Start[BEGIN transaction] --> Op1[UPDATE A: balance -= 100] Op1 --> Op2{Op2 successful?
UPDATE B: balance += 100} Op2 -->|✅ success| Commit[COMMIT
cả 2 thay đổi persist] Op2 -->|❌ throw| Rollback[ROLLBACK
A revert về cũ] Commit --> EndOk([State: A-100, B+100]) Rollback --> EndFail([State: A=original, B=original
tiền KHÔNG biến mất]) style Commit fill:#064e3b,stroke:#10b981 style Rollback fill:#7f1d1d,stroke:#ef4444 style EndOk fill:#022c22 style EndFail fill:#3f0f0f

2 mode của Prisma $transaction:

A. Batch (array form) — khi các op độc lập:

// Gửi 1 lượt, all-or-nothing. Không có logic giữa các op.
const [user, post] = await prisma.$transaction([
  prisma.user.create({ data: { name: 'Alice' } }),
  prisma.post.create({ data: { title: 'Hi', authorId: 1 } })
]);
// ✅ Đơn giản, nhanh
// ❌ Không dùng được nếu post.authorId cần lấy từ user vừa tạo

B. Interactive (callback) — khi có logic phụ thuộc:

await prisma.$transaction(async (tx) => {
  // tx là Prisma client trong transaction context
  const user = await tx.user.create({ data: { name: 'Alice' } });

  // Có thể dùng kết quả user.id ở đây
  await tx.post.create({
    data: { title: 'Hi', authorId: user.id }
  });

  if (someCondition) throw new Error('rollback');  // throw → rollback toàn bộ
}, {
  timeout: 10_000,         // mặc định 5s, tăng nếu cần
  isolationLevel: 'Serializable',
});

Isolation Levels (Postgres):

LevelDirty ReadNon-repeatablePhantomUse case
Read Uncommitted✅ cóHiếm dùng
Read Committed (default PG)Đa số app web
Repeatable ReadBáo cáo cần snapshot
SerializableTiền, kế toán, critical

Trade-off: level càng cao → an toàn càng cao + conflict càng nhiều (PG có thể throw serialization failure → app phải retry).

⚠️ Pitfalls:

Interview phrase: "Multi-table write phải atomic em dùng Interactive Transaction. Default Read Committed đủ cho hầu hết case, chỉ Serializable cho payment/critical."

5. Multi-tenancy · 3 patterns + trade-offs

Why care: Base.vn là SaaS B2B — mỗi company là 1 tenant, data phải isolate. Lựa chọn pattern ảnh hưởng tới cost, scale, isolation, ops effort.

3 Patterns (architecture visualization):

flowchart TB subgraph A[Pattern A · Shared Schema] direction TB AA[App] --> ADB[(1 DB · 1 schema)] ADB --> ATable["tasks
tenant_id=1
tenant_id=2
tenant_id=3"] end subgraph B[Pattern B · Schema-per-tenant] direction TB BA[App] --> BDB[(1 DB)] BDB --> BS1[schema tenant_1] BDB --> BS2[schema tenant_2] BDB --> BS3[schema tenant_3] end subgraph C[Pattern C · DB-per-tenant] direction TB CA[App] --> CDB1[(DB tenant_1)] CA --> CDB2[(DB tenant_2)] CA --> CDB3[(DB tenant_3)] end style A fill:#064e3b,stroke:#10b981 style B fill:#3f3f06,stroke:#eab308 style C fill:#3f0f0f,stroke:#ef4444

→ A: cost thấp / isolation yếu · B: trung gian · C: cost cao / isolation mạnh

A. Shared DB + Shared Schema + tenantId column:

-- Tất cả tenant cùng 1 bảng, phân biệt qua tenantId
CREATE TABLE tasks (
  id        SERIAL PRIMARY KEY,
  tenant_id INT NOT NULL,         -- ← key isolation
  title     TEXT,
  ...
);
CREATE INDEX idx_tasks_tenant ON tasks(tenant_id);

-- MỌI query phải có WHERE tenant_id = ?
SELECT * FROM tasks WHERE tenant_id = $1 AND id = $2;

B. Shared DB + Schema-per-tenant (Postgres schema):

-- Mỗi tenant có schema riêng trong cùng DB
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.tasks (id, title, ...);

CREATE SCHEMA tenant_globex;
CREATE TABLE tenant_globex.tasks (id, title, ...);

-- App set search_path mỗi connection
SET search_path TO tenant_acme;
SELECT * FROM tasks;  -- thực tế đọc tenant_acme.tasks

C. DB-per-tenant:

// App giữ map tenantId → connection string
const pool = getPoolForTenant(tenantId);
const result = await pool.query('SELECT * FROM tasks');
// Mỗi tenant 1 DB instance hoặc 1 DB logical

Decision matrix:

Yếu tốA. SharedB. SchemaC. DB-per-tenant
Cost$ thấp nhất$$ trung$$$ cao
IsolationYếu (app-enforced)Trung (schema)Mạnh (DB)
Scale 10k tenants⚠️ migration headache❌ cost balloon
Tenant rời + xoá dataDELETE WHEREDROP SCHEMADROP DATABASE
Default for SaaS⭐ Đa số chọnEdge caseEnterprise tier

⚠️ Pitfalls (cho Pattern A — phổ biến nhất):

Interview phrase: "Default em chọn shared schema + tenantId vì cost + scale. Enforce isolation qua Prisma middleware hoặc Postgres RLS. Khi có khách enterprise đòi data isolation, em mở tier riêng dùng DB-per-tenant."