Free

3.000 dòng ship một lần: vì sao tính năng phức tạp cần plan mode trước

Bản năng trước tính năng phức tạp là "cứ thử xem" — nhưng các quyết định kiến trúc nằm ẩn ở model thứ 3, edge case thứ 5. Giá trị thực của plan mode là đưa cuộc đối thoại cấp kiến trúc sang văn bản trước khi viết code. Case thật: hệ thống bồi thẩm cộng đồng của Pickful — 3.032 dòng, 119 spec xanh, một commit duy nhất, không một lần làm lại kiến trúc về sau.


Đối diện một tính năng phức tạp, phản xạ của phần lớn người là "cứ thử viết một ít xem".

Vấn đề: hệ thống phức tạp có một đặc điểm — các quyết định kiến trúc ẩn trong model thứ 3, edge case thứ 5, luật điểm thứ 8. Cắm đầu vào code rồi va vào những quyết định ấy ở giữa chừng, quay lại sửa, sẽ đắt hơn 10 lần so với bàn cho ra nhẽ ngay từ đầu ở cấp độ văn bản.

Tôi dùng plan mode của Claude Code để làm TopicReview, hệ thống bồi thẩm cộng đồng của Pickful, và chứng kiến phiên bản cực đoan của bất đẳng thức này: một commit, 3.032 dòng, 119 spec xanh, ship một lần. Không commit nào trong hơn chục commit sau đó là làm lại kiến trúc — tất cả đều là tinh chỉnh tham số, mài UI, vá edge case. Hệ thống từ đó chạy ổn định và giờ là cốt lõi cách cộng đồng tự kiểm duyệt.

Bài này nói về vì sao tính năng phức tạp cần plan mode trước, giai đoạn plan thực sự đang làm gì, và khi nào cuộc trao đổi đủ chín để bắt đầu viết code.

Hệ thống phức tạp đến mức nào

TopicReview là hệ thống biểu quyết cộng đồng để quyết định có xóa một bài post chất lượng thấp hay không. Một câu tóm gọn — nhưng spec mở ra từng lớp:

  • 5 trạng thái: open → voting → decided → appealed → closed
  • 3 phán quyết: remove / warn / keep
  • 2 giai đoạn: sơ thẩm do 12 bồi thẩm viên; kháng án do 5 thẩm phán (rút thăm từ 20 user có điểm cao nhất)
  • Dòng điểm đa chiều: phạt tạm 10 pt, stake kháng án 10 pt, +5 cho bồi thẩm bỏ phiếu theo phán quyết, +10 cho thẩm phán bỏ phiếu theo phán quyết, +3 cho người tố giác được xác nhận, hoàn trả khi thắng kháng án = stake + bonus + phạt tạm
  • 4 loại job lập lịch: cửa sổ sơ thẩm 24h, cửa sổ kháng án 24h, cửa sổ xét kháng án 24h, và — điểm bồi thẩm trì hoãn 24h sau phán quyết remove (vì kháng án có thể lật)
  • Luồng song song + rollback: nếu phiếu đảo chiều trong lúc removal tạm, phải khôi phục bài và hoàn phạt tạm; nếu kháng án lật phán quyết, trả stake cộng bonus, có thể kèm phạt tạm, và bồi thẩm được tính lại theo phán quyết mới

Cốt lõi của độ phức tạp không nằm ở từng luật đơn lẻ — mà ở cách các luật tương tác. Mỗi luật mới thêm vào đều có thể kích hoạt rollback ở chỗ khác.

Những bức tường bạn sẽ đâm vào nếu bỏ plan

Viết thẳng tuột, những vấn đề khó nhất không phải những sự thật nhìn thấy — mà là những câu hỏi bạn không nghĩ tới việc hỏi. Đọc ngược code của TopicReview, có ít nhất bốn bức tường chắc chắn sẽ đâm vào bạn nếu bỏ plan:

Luật điều kiện bồi thẩm. Nhìn tưởng chỉ là User.jurors_and_judges.sample(12). Nhưng luật thật là: loại tác giả bài viết, loại người tố giác, loại người đã bỏ phiếu sơ thẩm (để cùng một người không bỏ phiếu cả ở kháng án). Ba tầng loại trừ. Viết model một lèo, bạn dễ sót một-hai cái.

Trả điểm trì hoãn. Phán quyết remove bình thường sẽ kích hoạt trả điểm cho bồi thẩm. Nhưng kháng án có thể lật remove — khi lật, những bồi thẩm theo phán quyết cũ lại sai phe, nên điểm phải tính lại theo phán quyết mới. Do đó phán quyết remove phải đợi cửa sổ kháng án 24h đóng rồi mới trả điểm bồi thẩm. Bỏ viết luật này, trả sớm, thế là đi thu lại điểm — phiền gấp 10 lần trả muộn.

Thiết kế interface của job lập lịch. CloseTopicReviewJob trông như "kết thúc một review". Thực tế nó xử lý ba tình huống:

# Hết hạn cửa sổ sơ thẩm
CloseTopicReviewJob.set(wait_until: voting_ends_at).perform_later(review.id)
# Trả điểm bồi thẩm trì hoãn sau phán quyết remove
CloseTopicReviewJob.set(wait: 24.hours).perform_later(review.id, award_juror_points: true)
# Hết hạn cửa sổ kháng án
CloseTopicReviewJob.set(wait: 24.hours).perform_later(review.id, appeal_id: appeal.id)

Không sắp xếp trước, bạn viết signature đầu tiên, đến case hai thì interface phải thiết kế lại.

Bức tường đắt nhất: có thể kháng án trong lúc removal tạm không? Khi phiếu remove vượt ngưỡng, bài bị ẩn ngay (tạm), nhưng cả review vẫn ở trạng thái voting — chưa decided. Người dùng có thể kháng án bây giờ không?

  • Có → phải mở rộng các transition giữa decidedappealed
  • Không → UX tệ: bài đã bị ẩn và user phải đợi 24h mới có thể phản đối
  • Chọn thực tế của TopicReview: có, kháng án được phép trong lúc removal tạm — nhưng review phải chạy qua finalize → decided trước khi mở appeal

Một quyết định đó lan ngược lại và thay đổi cả kiểm tra tham số trong open_appeal! và logic state machine. Quyết định giữa chừng = viết lại nửa hệ thống.

Giai đoạn plan thực sự đang làm gì

Plan mode của Claude Code là một chế độ không cho viết code — Claude có thể đọc repo, nghĩ cách, trao đổi với bạn, nhưng mọi sửa file đều bị hard-block cho đến khi bạn duyệt một plan.

Ràng buộc máy móc đó chính là điểm chính: nó ép cuộc trao đổi cấp kiến trúc xảy ra ở văn bản.

Vài việc giai đoạn plan đang làm trong thực tế:

1. Vẽ state machine và vai trò. 5 trạng thái, 4 vai trò (bồi thẩm / thẩm phán / tác giả / người tố giác), mỗi vai trò làm được gì và không làm được gì ở mỗi trạng thái — tất cả gói trong vài dòng markdown. Vài dòng vs. hàng chục file. Chi phí thay đổi cách nhau hai bậc lớn.

2. Đi qua luồng của từng vai trò:

  • Bồi thẩm: nhận thông báo → mở review → đọc bài và lý do → bỏ phiếu kèm reasoning → nhận điểm
  • Tác giả: nhận thông báo → xem phán quyết → nếu remove cân nhắc kháng án → nộp stake → đợi
  • Thẩm phán: nhận appeal được rút thăm từ top-20 → bỏ phiếu → nhận điểm

Chỗ nào đi không trôi, câu hỏi ẩn lập tức lộ ra: "bồi thẩm có thấy phiếu của bồi thẩm khác không?", "tác giả làm gì được trong lúc removal tạm?"

3. Thẩm vấn edge case. Giai đoạn plan không thiết kế "đường bình thường" — nó cố ý hỏi những câu mà bình thường không nghĩ đến:

  • Nếu cửa sổ hết hạn mà 0 phiếu thì sao? (Quyết định cuối: mặc định keep.)
  • Nếu kháng án hết hạn mà 0 phiếu thì sao? (dismissed; stake mất.)
  • Thẩm phán có bỏ phiếu được cho bài chính mình tố giác không? (Không — cùng luật loại trừ.)
  • Nhiều bồi thẩm cùng lúc kích hoạt finalize — race có trả điểm hai lần không? (Thêm trường juror_points_awarded để idempotent.)

95% những câu hỏi này không tự nhiên nổi lên khi viết code. Giai đoạn plan ép bạn đi qua từng câu.

4. Sổ cái điểm. Hệ thống điểm phức tạp đến mức thảo luận thôi là không đủ — phải thực sự vẽ bảng: mỗi dòng điểm với điều kiện kích hoạt + số lượng + đường rollback. Khi sổ cái cân, tất cả edge case (hoàn phạt tạm, hoàn kháng án, bonus) đều đối chiếu được.

Nói tới mức nào thì bắt đầu viết code

Một tiêu chí cứng:

  • Đi được qua mọi đường mà không kẹt — mọi tổ hợp từ mở review đến closed (keep / remove / warn × có kháng án / không × provisional / không) — kể được mạch lạc từ đầu đến cuối
  • Mỗi edge case đều có hành vi xác định — không phải "lát tính", mà "0 phiếu mặc định keep", "provisional cho phép appeal nhưng phải finalize trước"
  • Không có "ủa cái này chưa nghĩ" — mọi câu hỏi bạn có thể nghĩ ra đều đã có câu trả lời

Đạt chuẩn đó, viết code trở thành dịch plan sang Ruby.

Giai đoạn thi công một mạch trông ra sao

Ship đầu tiên của hệ thống:

d162f1e Add community moderation system with jury/judge review and appeals
  63 files changed, 3032 insertions(+)
  119 specs, 0 failures

63 file, 3.032 dòng, 119 spec xanh. Ship một lần.

Hơn chục commit sau xác nhận kiến trúc đứng vững:

Apply legacy penalty (1pt) for posts created before 2026-03-26
Fix topic review appeal bugs: window mismatch and verdict not updated
Add topic_removed status for posts removed by community review
Default to keep verdict when review expires with zero votes
Reduce provisional removal penalty to 1pt during trial period
Allow topic creator to withdraw active reviews
Add handled tab to jury dashboard, fix tabs styling

Mỗi cái đều là tinh chỉnh / vá / mài / thêm tính năng nhỏ. Không cái nào là "quay lại thiết kế lại state machine" hay "mô hình điểm phải đổi". Bộ khung đứng đúng.

Đây là phần thưởng thực sự của plan: thi công không bị cắt ngang. Không có "khoan đã, đường này xử lý ra sao" — plan đã nói. Không có "edge case này tôi không nghĩ tới" — plan đã hỏi. Không có "interface này phải thiết kế lại" — plan đã gọn.

Nhiều giờ viết code, chỉ làm một việc: dịch thiết kế rõ ràng xuống bàn phím.

Khi nào không nên dùng plan

Không phải nhiệm vụ nào cũng đáng plan:

  • Sửa bug đơn giản — định vị + sửa + thêm regression test; không cần thảo luận trước
  • Refactor cơ học — đổi tên, trích hàm, dời file; đường đi duy nhất, plan là bước thừa
  • Chỉ có một đường rõ ràng — thêm một GET endpoint, một UI toggle; không có gì để tranh cãi
  • Prototype thăm dò — chính bạn còn chưa biết muốn gì; chạy bản thô thắng việc nghĩ thêm

Lợi ích của plan mode đến từ hạ chi phí viết lại. Nếu công việc không có rủi ro viết lại, plan chỉ là chi phí thừa.

Kết

"Nghĩ trước khi làm" nghe như lời khuyên về tính cách. Plan mode không phải như vậy.

Plan mode là làm cho cuộc trao đổi cấp kiến trúc xảy ra ở nơi 10 chữ thay đổi được — không phải ở nơi 30 file phải thay đổi.

Dưới độ phức tạp, câu nói cũ của ngành "words are cheap, code is expensive" cần được đảo — không có nghĩa "cứ viết code trước rồi xem" (điều đó chỉ đúng khi chênh lệch chi phí nhỏ), mà tận dụng chênh lệch chi phí: đưa cuộc trao đổi đắt lên trước, trong môi trường rẻ.

3.000 dòng ship một lần có cảm giác rất đã. Không phải vì tôi viết nhanh — mà vì plan biến "viết" thành hành động thuần cơ học.