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;
}
→ 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ỏ."
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
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:
Verb
Purpose
Idempotent?
Safe?
GET
Read
✅
✅ (no side effect)
POST
Create (server gen ID)
❌
❌
PUT
Replace toàn bộ resource (client biết ID)
✅
❌
PATCH
Update partial fields
⚠️ Depends (mostly yes)
❌
DELETE
Remove
✅ (delete twice = same state)
❌
Status Code Cheat:
Code
Meaning
When dùng
200 OK
Success + body
GET, PUT/PATCH return updated
201 Created
Resource created
POST → trả new entity + Location header
204 No Content
Success, no body
DELETE thành công
400 Bad Request
Client validation fail
Zod schema fail, missing field
401 Unauthorized
Chưa login / token invalid
Missing/expired JWT
403 Forbidden
Login rồi nhưng không có quyền
RBAC fail (user ≠ owner)
404 Not Found
Resource không tồn tại
GET /users/999 không thấy
409 Conflict
State conflict
Email duplicate, optimistic lock fail
422 Unprocessable
Syntax OK, semantic fail
Date past in future-only field (optional, thường gộp vào 400)
429 Too Many
Rate limit
API throttle
500 Server Error
Unhandled exception
Logic 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):
HTTP idempotent = gọi N lần cho cùng side-effect cuối cùng. GET/PUT/DELETE idempotent theo spec.
Business idempotent = không tạo duplicate (xem BE Async #3). Cần Idempotency-Key header + log table.
⚠️ Pitfalls:
Trả 200 cho POST create (đúng là 201) → client không biết được URI của resource mới
Trả 401 khi đáng lý 403 → confuse client logic (401 = login lại, 403 = bị deny dù đã login)
Trả 200 + { error: '...' } trong body → anti-pattern. Status code phải reflect kết quả.
PUT để partial update → vi phạm spec, gây confusion
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:
Mỗi lần refresh → server cấp refresh token mới + invalidate cũ
Nếu attacker dùng refresh cũ → server detect (vì cũ đã invalidated) → revoke toàn bộ session
Đây gọi là reuse detection, recommend bởi OWASP
Storage trade-offs:
Storage
XSS safe?
CSRF safe?
Notes
localStorage
❌ JS đọc được
✅
Nếu site có XSS → token bị steal
Cookie httpOnly
✅ JS không đọc
❌ Cần CSRF token
Set Secure + SameSite=Lax/Strict
Memory (in-memory)
✅ best
✅
Mấ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:
Để PII (CMND, phone) trong payload → ai có token cũng đọc được
JWT không revoke được giữa chừng (stateless) → nếu cần logout-all-devices thì phải blacklist refresh trong DB/Redis
Đặt access expiry quá dài (1 ngày) → leak là chết. Stick với 15-30 phút.
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.
Storage
Reload 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:
Attacker chèn XSS → script chỉ đọc được token lúc chạy, không persist được
App khởi động chạy bootstrap flow: POST /auth/refresh (cookie httpOnly tự gửi) → cấp access token mới → lưu vào memory
→ User KHÔNG bị logout dù access token mất khi reload
// 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:
Lưu vào window.accessToken → global, mọi script đọc được
Dùng Zustand persist middleware → ghi sang localStorage → mất tính memory-only
Quên bootstrap refresh flow khi page load → user reload bị "logout giả"
Lưu vào React state cấp page-level → fetch interceptor không truy được → 401
Lưu vào IndexedDB vì nghĩ "an toàn hơn localStorage" → vẫn JS-accessible, vẫn XSS-able
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:
Server tự sinh key → retry server cấp key mới mỗi lần → vô nghĩa. Key PHẢI từ client.
Quên hash body → attacker dùng cùng key gửi body khác để gian lận
TTL quá ngắn (5 phút) → user retry sau timeout vẫn charge 2 lần
Áp dụng cho GET/DELETE → không cần, đã idempotent native
Áp dụng cho mọi POST → chỉ cần cho operation tốn kém: payment, send email, tạo order. POST /comments không cần.
Charge thành công nhưng log chưa kịp lưu → retry sẽ charge lần 2. Fix: DB transaction bọc cả 2 op.
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
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:
Quên xét nested N+1 (user.posts.comments) → include phải nested theo
Bật query logging Prisma (log: ['query']) → phát hiện N+1 ngay khi dev
REST endpoint thường KHÔNG cần DataLoader — chỉ cần include/select đúng
Include quá tham (deep nested) → query nặng, nên cap depth + dùng select
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.
// 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):
Level
Dirty Read
Non-repeatable
Phantom
Use case
Read Uncommitted
✅ có
✅
✅
Hiếm dùng
Read Committed (default PG)
❌
✅
✅
Đa số app web
Repeatable Read
❌
❌
✅
Báo cáo cần snapshot
Serializable
❌
❌
❌
Tiề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:
Để API call (Stripe, Email) BÊN TRONG transaction → giữ DB lock quá lâu, deadlock. Rule: chỉ DB op trong tx.
Transaction timeout mặc định 5s — query nặng phải tăng timeout + maxWait
Không throw → Prisma không rollback. Phải throw để abort.
Dùng client gốc (prisma.user...) thay vì tx.user... trong callback → op đó KHÔNG nằm trong transaction
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;
✅ Cost thấp nhất, scale tốt nhất (1 DB cho 10000 tenant)
✅ Migration đơn giản (1 schema)
❌ Quên WHERE tenant_id → leak data giữa tenant (security thảm hoạ)
❌ Khó isolate noisy neighbor (1 tenant query nặng làm chậm cả app)
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
✅ Isolation mạnh hơn (không leak nhờ search_path)
✅ Backup/restore per tenant dễ hơn
❌ Migration cực phức tạp (1000 tenant × N schema)
❌ Prisma không support tốt — phải multi-client hoặc raw SQL
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
✅ Isolation cao nhất (compliance enterprise, GDPR)
Quên WHERE tenant_id → đại hoạ security. Mitigate: Prisma middleware tự inject, hoặc Postgres Row-Level Security (RLS).
Index thiếu tenant_id ở vị trí đầu → query tenant nhỏ phải scan toàn bảng
JOIN sang bảng khác quên check tenant_id → leak
UNIQUE constraint phải composite với tenant_id (vd: UNIQUE(tenant_id, email) chứ không phải UNIQUE(email))
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."