Bài 10: Trade-off & Anti-patterns — Khi nào phá quy tắc¶
Đọc cuối bộ. Bài này không dạy kỹ thuật mới. Nó dạy khi nào KHÔNG áp dụng các kỹ thuật bạn vừa học.
Pattern là câu trả lời, Trade-off là câu hỏi¶
Junior developer học được pattern rồi áp dụng khắp nơi mà không cần hiểu tại sao — đây là cargo cult programming: làm theo hình thức mà không hiểu lý do.
Senior developer nhìn bối cảnh → chọn hoặc phá pattern dựa trên trade-off.
Mỗi quyết định design đều có cost. Câu hỏi không phải "pattern này đúng không?" mà là "trong bối cảnh này, cost có đáng không?"
1. 3-Layer Architecture (Bài 5) — Khi nào phản tác dụng?¶
Không cần 3 layer khi¶
A. Script 1 lần (migration, seeding, cron job):
# ✅ OK — script migration, chạy 1 lần rồi bỏ
async def migrate_v2():
async with aiosqlite.connect(DB) as conn:
await conn.execute("ALTER TABLE audio ADD COLUMN tags TEXT")
await conn.commit()
Thêm Router/Service/Repo ở đây = boilerplate vô nghĩa. Không có business logic để isolate.
B. Endpoint proxy DB thuần, không logic:
# Healthcheck — không có business rule
@router.get("/health")
async def health():
return {"status": "ok"}
# Lookup trivial — chỉ SELECT
@router.get("/categories")
async def list_categories(session=Depends(get_session)):
result = await session.execute(select(categories))
return {"data": [dict(r._mapping) for r in result.fetchall()]}
Service layer chỉ delegate không thêm value → bỏ được. Project này vẫn giữ cho consistency, nhưng chọn bỏ cũng hợp lý.
C. Project prototype/MVP < 3 entity:
2 tuần MVP demo → gom hết vào 1 file. Khi product-market fit xong mới split layer.
Dấu hiệu 3-layer đang thừa¶
- Service method chỉ gọi đúng 1 repo method không thêm gì
- Repo có method y hệt service (chỉ wrap)
- Tất cả Router/Service/Repo đổi cùng lúc mỗi lần đổi requirement
Đây là dấu hiệu abstraction không phản ánh concern thực. Lúc này nên gộp lại, bỏ bớt layer thừa.
2. SQLAlchemy Core (Bài 4) — Khi nào không đủ¶
Project chọn Core vì: SQLite đơn giản, SQL control quan trọng, tránh N+1.
Khi nào nên chuyển sang ORM¶
| Tình huống | Tại sao Core khó |
|---|---|
| Nhiều relationship lồng (user → posts → comments → reactions) | Core phải manually JOIN + group, verbose |
| Polymorphism (inheritance table) | ORM có polymorphic_on, Core phải UNION tay |
| Unit of Work pattern (multi-entity transaction phức tạp) | ORM tracking identity map, Core tự quản |
| Team đã quen ORM Django/Rails | Learning curve ngược |
Khi nào nên bỏ hẳn SQLAlchemy (Core lẫn ORM)¶
- Chỉ 1-2 table, query cực đơn giản →
aiosqlitetrực tiếp gọn hơn - App cần raw SQL tối ưu (OLAP, window functions phức tạp) →
asyncpg+ SQL string - Không dùng SQL (Mongo, DynamoDB, Redis)
Đừng dùng SQLAlchemy vì "best practice". Dùng vì giải quyết vấn đề cụ thể.
3. Vanilla JS (Bài 6) — Anti-pattern mở rộng¶
Khi nào Vanilla JS hết duyên¶
| Symptom | Nguyên nhân | Giải pháp |
|---|---|---|
| > 20 màn hình, reuse card/form khắp nơi | Không có component reuse | React/Vue |
| State share giữa 5+ module qua CustomEvent | Coupling qua event ngầm | State management (Redux/Pinia) |
render() chạy mỗi keystroke gây lag |
Không diffing, re-render toàn container | Virtual DOM |
| Form validation phức tạp, nested data | Manual binding lặp | Form library (React Hook Form, VeeValidate) |
| SEO quan trọng (public site) | Cần SSR | Next.js / Nuxt |
Anti-pattern: Vanilla JS + jQuery + inline <script>¶
Không cần Vanilla JS thuần túy để "không dùng framework". Bài 6 pattern này chỉ work khi bạn viết ES modules + Vite build pipeline. Đổ HTML inline + script tag ở giữa = loạn, không maintain được.
4. JWT (Bài 8) — Khi nào không nên dùng¶
Trade-off thực sự của JWT¶
| Ưu | Nhược |
|---|---|
| Stateless — server không lưu session | Không revoke được (phải chờ expiry) |
| Scale ngang dễ (không share session store) | Payload lộ qua base64 decode |
| Cross-domain (mobile app, microservice) | Khó invalidate khi user đổi password |
Khi nào session cookie + server store tốt hơn¶
- App nội bộ, 1 server → redis session đơn giản hơn, revoke instant
- Cần force logout khi đổi password → JWT phải thêm "token version" phức tạp
- Cần track active session của user → session store tự có
Refresh Token — khi nào cần¶
Project này JWT TTL 7 ngày — user expire tự re-login.
Production nghiêm chỉnh thường có:
- Access token (15 phút) — dùng mỗi request
- Refresh token (30 ngày) — dùng để lấy access token mới, có thể revoke
Lý do: access token ngắn → rò rỉ ít hại. Refresh token lưu DB → admin có thể revoke.
Project KCDS dùng JWT 7 ngày + fetch role từ DB mỗi request (bài 8 §3). Đây là pragmatic compromise: không có refresh token nhưng role change có hiệu lực ngay.
5. Async/Await (Bài 2, 6) — Khi nào KHÔNG giúp¶
Async chỉ giúp khi có I/O wait. Với CPU-bound work, async không song song được — vẫn chiếm event loop.
Anti-pattern: CPU-bound trong handler¶
# ❌ Block event loop 500ms
@router.post("/compress")
async def compress(file: UploadFile):
data = await file.read()
compressed = zlib.compress(data, level=9) # CPU-bound, synchronous
return {"size": len(compressed)}
Trong 500ms đó, không request nào khác được xử lý. Async không giúp CPU.
Đúng: offload sang thread pool¶
@router.post("/compress")
async def compress(file: UploadFile):
data = await file.read()
compressed = await asyncio.to_thread(zlib.compress, data, 9) # offload
return {"size": len(compressed)}
Hoặc dùng ProcessPoolExecutor cho CPU-intensive task không có GIL.
Khi nào nên bỏ async hoàn toàn¶
- App CPU-bound thuần (ML inference, image processing, encryption bulk)
- Team không quen async → sync code đơn giản hơn, bug ít hơn
- Scale bằng process/container thay vì concurrency
FastAPI support cả def và async def. Handler def tự chạy trong threadpool — hợp cho sync library (blocking DB driver).
6. Dependency Injection (Bài 3) — Premature Abstraction¶
Anti-pattern: DI cho mọi thứ¶
# ❌ Over-engineered
def get_logger() -> Logger: return logging.getLogger(__name__)
def get_datetime_now() -> Callable: return datetime.now
def get_uuid() -> Callable: return uuid.uuid4
@router.get("")
async def handler(
logger=Depends(get_logger),
now=Depends(get_datetime_now),
uid=Depends(get_uuid),
):
...
Để test datetime.now? Dùng freezegun. Để test logging? Dùng caplog fixture của pytest. Không cần DI cho stdlib.
DI đúng cho¶
- External service (HTTP client, DB, queue) — test mock được
- Business abstraction (Repository, EmailSender, PaymentGateway)
- Config (khi cần override trong test)
DI sai cho
- Pure function (
hashlib,json) - Stdlib có công cụ test riêng (
datetime,uuid,randomvới seed) - Constant
7. Abstract Base Class (Bài 5) — Khi nào 1 impl là đủ¶
# ❌ Abstract cho 1 implementation
class AudioRepository(ABC): # chỉ có 1 impl SqliteAudioRepository
@abstractmethod
async def list_all(self, session): ...
Nếu chắc chắn không bao giờ có implementation thứ 2 → ABC là overhead.
Nhưng project này giữ ABC vì:
- Test — Mock tuân thủ spec (bài 9 §4)
- Tài liệu — Interface = contract rõ ràng
- Future-proof có lý — SQLite → Postgres migration có thể xảy ra
Nguyên tắc: Abstract khi có ≥ 2 lý do trong 3 lý do trên. Chỉ 1 lý do → duck-typing đủ.
8. Polling qua SQLite (Bài 7) — Scale limit¶
Project dùng bot_commands table + polling 2s. Work tốt cho 1 admin, 1 bot, vài lệnh/phút.
Khi nào pattern này gãy¶
| Symptom | Nguyên nhân |
|---|---|
| Latency 2s không chấp nhận được | Polling interval |
| Multiple bot instance | Race condition khi poll |
| > 100 command/s | SQLite lock contention |
Giải pháp khác¶
- Redis Pub/Sub — near-zero latency, scale ngang
- PostgreSQL LISTEN/NOTIFY — DB-native pub/sub
- Message Queue (RabbitMQ, SQS) — durable + fan-out
- WebSocket — admin panel push trực tiếp xuống bot
Project chọn SQLite polling vì đơn giản + 1 bot + latency 2s acceptable. Đây là YAGNI áp dụng đúng.
9. Rollback file-first vs DB-first (Bài 5)¶
Project chọn:
- Upload: file trước, DB sau → DB lỗi thì
unlinkfile - Delete: DB trước, file sau → file lỗi chỉ log
Khi nào đảo ngược hợp lý hơn¶
Upload DB trước, file sau — hợp khi:
- File storage external (S3, CDN) — rollback file tốn network
- DB có unique constraint quan trọng (tránh race condition tên file)
- Chấp nhận record "orphan DB without file" (app tự skip)
Delete file trước, DB sau — hợp khi:
- Compliance (GDPR) — phải chắc file xóa trước khi audit log DB
- File đắt tiền lưu trữ (cloud bill tính theo GB)
Không có pattern đúng tuyệt đối. Chọn dựa trên: cost rollback, consequence của orphan state, compliance.
10. Copy-paste vs Abstraction¶
Copy 2 lần: OK¶
# handler A
user = await user_repo.get_by_email(session, email)
if not user: raise HTTPException(404)
# handler B
user = await user_repo.get_by_email(session, email)
if not user: raise HTTPException(404)
Copy 2 lần. Chưa nên abstract.
Copy 3 lần: nghĩ về abstraction¶
# handler C — lần thứ 3
user = await user_repo.get_by_email(session, email)
if not user: raise HTTPException(404)
Giờ mới nên tạo helper await get_user_or_404(session, email).
Tại sao? Abstract quá sớm = abstraction sai. 3 chỗ dùng giúp bạn thấy pattern thật — 2 chỗ chỉ là "tình cờ giống".
Dấu hiệu abstraction sai¶
- Helper có nhiều parameter optional — mỗi caller pass khác nhau
- Helper có flag (
should_log=True, use_cache=False) — từng caller cần behavior khác - Helper tên generic (
process_data,handle_request)
→ Gộp 3 use case khác nhau vào 1 hàm = sai abstraction. Tách lại.
11. Khi nào phá SOLID có chủ đích¶
S — Single Responsibility¶
AudioService.upload() làm cả validation + file + DB. Vi phạm SRP?
Thực ra không. "Upload audio" là 1 responsibility ở business level. Chia nhỏ thành FileWriter + MetadataInserter chỉ làm code verbose hơn.
Nguyên tắc: SRP ở abstraction level nào? Level business, không level implementation detail.
O — Open/Closed¶
Nếu thêm feature phải sửa code cũ → vi phạm OCP.
Nhưng trong project MVP, sửa 3 dòng còn đơn giản hơn tạo Abstract + Factory. OCP quan trọng khi code được consume bởi external (library). Code nội bộ → YAGNI thắng.
D — Dependency Inversion¶
Hardcode SqliteAudioRepository trong router? Vi phạm DIP.
Nhưng router chưa bao giờ có 2 implementation → DIP chưa cần. Project này DI vì lợi ích test, không vì "switch implementation".
13. Stateful local storage vs stateless instance¶
Project ghi audio file vào /data/audio/ local (bài 5). Đơn giản, nhanh, work tốt với 1 server.
Nhưng biến server thành stateful:
- Không restart/recreate container mà mất data
- Không scale ngang (instance 2 không thấy file của instance 1)
- Backup phải rsync/snapshot filesystem
- Blue-green deploy khó (instance mới không có file cũ)
Khi nào chuyển sang object storage (S3, MinIO, Cloudflare R2)¶
| Trigger | Lý do |
|---|---|
| Deploy > 1 instance | Stateless là điều kiện bắt buộc để scale ngang |
| Kubernetes / ECS | Container ephemeral — local disk mất khi restart/reschedule |
| Blue-green / canary deploy | Instance cũ + mới phải share storage |
| Compliance (encryption at rest, lifecycle, versioning) | Object storage có sẵn, filesystem phải tự làm |
| Data > 1TB | Object storage tính per-GB rẻ hơn managed disk |
| CDN cho audio/video | S3 tích hợp CloudFront/Cloudflare sẵn |
Khi nào stateful đúng đắn¶
- Single-server deploy (VPS, bare metal, docker-compose 1 host) — như project KCDS
- Data < 100GB, ít thay đổi
- Latency quan trọng hơn scalability (đọc file local < 1ms, S3 ~20-50ms)
- Team không muốn manage IAM + credential rotation
Chi phí migration¶
Chuyển local → S3 sau khi đã có 100GB+ data = migration đau:
- Upload batch (có thể mất vài ngày cho TB data)
- Đổi URL pattern trong DB (signed URL thay đường dẫn local)
- Update backup strategy (snapshot → S3 versioning)
- Test lại toàn bộ upload/download flow
Nguyên tắc:
- Biết chắc sẽ scale ngang → stateless từ đầu (overhead 1-2 ngày, tiết kiệm sau này)
- Không chắc → YAGNI, local trước, migrate khi cần. Nhưng thiết kế repository sao cho đổi backend dễ — đây là lý do
AudioRepositoryabstract ở bài 5.
Bonus: Abstract storage cho migration path dễ¶
class AudioStorage(ABC):
@abstractmethod
async def save(self, filename: str, data: bytes) -> None: ...
@abstractmethod
async def load(self, filename: str) -> bytes: ...
@abstractmethod
async def delete(self, filename: str) -> None: ...
class LocalAudioStorage(AudioStorage):
def __init__(self, base_dir: Path):
self.base_dir = base_dir
async def save(self, filename, data):
await asyncio.to_thread((self.base_dir / filename).write_bytes, data)
class S3AudioStorage(AudioStorage):
def __init__(self, bucket, client):
self.bucket = bucket
self.client = client
async def save(self, filename, data):
await self.client.put_object(Bucket=self.bucket, Key=filename, Body=data)
Service chỉ biết AudioStorage (bài 5 §3 DIP). Migration local → S3 = đổi inject LocalAudioStorage sang S3AudioStorage ở factory, không sửa service.
Project hiện tại không có abstraction này — file I/O nằm thẳng trong AudioService (xem bài 5 §4). Trade-off có chủ đích: YAGNI cho MVP single-server. Nếu biết sẽ scale → tách ngay.
14. Bài tập phản biện¶
-
Scenario: Team yêu cầu "tất cả endpoint phải có 3 layer" — kể cả healthcheck và version endpoint. Bạn phản biện thế nào?
-
Scenario: Junior đề xuất dùng JWT cho mọi internal API. Khi nào bạn đồng ý, khi nào khuyên dùng cookie/session?
-
Scenario: Code có 3 class
EmailValidator,PhoneValidator,UrlValidator— tất cả implementValidatorinterface. Nhưng mỗi class chỉ được dùng 1 chỗ duy nhất. Có nên gộp lại không? Trade-off gì? -
Scenario: App xử lý 50 request/s, mỗi request chạy ML inference 100ms (CPU). Async có giúp không? Giải pháp gì?
-
Scenario: DB migration thêm cột
NOT NULLvào bảng có 10M row. Pattern "fail-fast" trong startup có phù hợp không? Nếu không, đổi thế nào?
Câu hỏi tự kiểm tra¶
- Kể 3 dấu hiệu 3-layer architecture đang thừa.
- Khi nào nên bỏ async hoàn toàn khỏi project?
- JWT khi nào thua session cookie?
- "Copy 2 lần, abstract lần 3" — tại sao?
- SRP có áp dụng được cho hàm 200 dòng không? Nếu có, tại sao? Nếu không, lý do gì?
Kết¶
Bạn đã qua 10 bài guide. Ba điều rút ra:
- Pattern là giải pháp cho bài toán cụ thể. Không copy blindly.
- Nguyên lý (bài 0) là cách tư duy. Hiểu rồi sẽ tự chọn pattern phù hợp bối cảnh.
- Trade-off là bản chất của kỹ thuật. Không có best practice tuyệt đối — chỉ có "best trong bối cảnh X".
Senior developer khác junior chủ yếu ở khả năng nhìn ra trade-off, không phải ở việc biết nhiều pattern hơn.