← Clean Code: viết code dễ đọc

Bài 11 · Nâng cao · 20 phút

Thiết kế đơn giản (Emergence)

Biên soạn bởi Nguyễn Anh Tuấn

Bốn quy tắc thiết kế đơn giản (Kent Beck): chạy hết test → không trùng lặp → diễn đạt rõ ý định → tối thiểu phần tử; thiết kế "nổi lên" qua refactor.

Có một câu hỏi hay xuất hiện khi bắt đầu dự án: "Tôi cần thiết kế cấu trúc class trước hay cứ code đi rồi tính?" Câu trả lời thực dụng: không cần phải vẽ hết kiến trúc trước - thiết kế tốt nổi lên (emerges) qua quá trình cải tiến liên tục.

Ý tưởng này - thường gọi là emergent design - không có nghĩa là ngẫu hứng hay không có định hướng. Nó có nghĩa là: viết code đủ đơn giản để test, rồi liên tục hỏi "cái này có thể tốt hơn không?" và refactor. Thiết kế đúng sẽ lộ ra từ những câu hỏi đó.

  • Thiết kế trước ở mức kiến trúc tổng thể vẫn hữu ích - emergent design áp dụng cho thiết kế cục bộ.
  • Refactor liên tục là cơ chế làm thiết kế tốt nổi lên - không phải ngẫu nhiên.
  • Bài code-smell-refactor vừa học đã là thực hành emergent design mà chưa đặt tên cho nó.

Kent Beck - người đặt nền tảng cho Extreme Programming và TDD - đề xuất bốn quy tắc để kiểm tra xem một thiết kế có đủ đơn giản chưa. Chúng có thứ tự ưu tiên rõ ràng: quy tắc trên quan trọng hơn quy tắc dưới.

Thứ tựQuy tắcÝ nghĩa thực tế
1Chạy hết testCode làm đúng điều nó hứa hẹn - có bằng chứng, không chỉ tin tưởng.
2Không trùng lặp (DRY)Mỗi ý chỉ có một chỗ định nghĩa - sửa một chỗ là đủ.
3Diễn đạt rõ ý địnhTên, cấu trúc nói rõ code làm gì - người đọc sau không cần đoán.
4Tối thiểu phần tửKhông tạo class/method/abstraction thừa - mỗi phần tử tồn tại vì lý do cụ thể.
  • Quy tắc 1 là điều kiện tiên quyết: nếu chưa chạy hết test thì ba quy tắc kia chưa có ý nghĩa.
  • Quy tắc 2 và 3 là động lực chính của refactor - xoá lặp và làm rõ ý định.
  • Quy tắc 4 là lực cân bằng: đừng trừu tượng hoá vì "tương lai có thể cần" khi chưa cần.

Hai cái tên bạn sẽ gặp: KISS & YAGNI

Hai khẩu hiệu quen thuộc thực ra chỉ là bốn quy tắc trên gọi cho gọn. KISS (Keep It Simple, Stupid - "cứ giữ cho đơn giản") là luận điểm xuyên suốt bài: chọn giải pháp đơn giản nhất mà vẫn chạy đúng. YAGNI (You Aren't Gonna Need It - "rồi cũng chẳng cần đến đâu"), một khẩu hiệu của Extreme Programming, chính là quy tắc 4: đừng thêm class, interface hay abstraction cho nhu cầu tương lai chưa xuất hiện. Khi các khoá khác (vd SOLID) nhắc tới KISS/YAGNI, gốc của chúng là ở đây.

Bốn quy tắc không phải danh sách kiểm một lần - chúng là vòng lặp liên tục. Mỗi tính năng nhỏ đi theo vòng này:

Vòng 1 - Chạy hết test: viết test trước, code tối thiểu để xanh

// Test truoc - mo ta hanh vi mong muon
// (Vitest)
it('tinh tong gia don hang', () => {
  const items = [
    { price: 100_000, qty: 2 },
    { price: 50_000,  qty: 3 }
  ];
  expect(calcTotal(items)).toBe(350_000);
});

// Code toi thieu de test xanh - chua dep nhung dung
function calcTotal(items: Array<{ price: number; qty: number }>): number {
  let sum = 0;
  for (const item of items) sum += item.price * item.qty;
  return sum;
}

Vòng 2 - Xoá lặp & diễn đạt rõ: refactor sau khi test xanh

// Xoa lap: reduce ngan hon, ro hon
// Dien dat ro: kieu OrderItem dat ten, tham so ten day du
interface OrderItem {
  price: number;
  qty: number;
}

function calcTotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

// Test van xanh - hanh vi khong doi, chi cau truc tot hon

Vòng 3 - Tối thiểu phần tử: đừng thêm gì khi chưa cần

// DUNG: ham co ket qua tinh xac, kieu ro rang, test xanh
// KHONG NEN: tao interface ICalculator chi vi "co the can sau"
// KHONG NEN: tao abstract class BaseCalculator khi chi co mot implementation
// KHONG NEN: them DiscountStrategy pattern khi chua co yeu cau giam gia

// Quy tac 4: moi abstraction can mot ly do HIEN TAI, khong phai tuong lai
  • Test trước (TDD) tạo điều kiện để refactor an toàn - quy tắc 1 làm nền cho 2, 3, 4.
  • Refactor xoá lặp và làm rõ ý định là bước chính của emergent design.
  • Ngừng refactor khi không còn lặp, ý định đã rõ, và không có gì thừa - đó là "đủ tốt".

Xem một ví dụ đi qua cả bốn quy tắc trên một đoạn code xử lý đăng ký người dùng:

Điểm xuất phát - code chạy nhưng chưa qua bộ lọc nào

function registerUser(email: string, pw: string, pw2: string): boolean {
  if (!email.includes('@')) return false;
  if (pw.length < 8) return false;
  if (pw !== pw2) return false;
  db.insert({ email, password: hashPw(pw), createdAt: new Date() });
  return true;
}

// Van de: khong co test; logic lon; magic number 8 khong ten

Sau quy tắc 1 & 2 - test phủ, xoá lặp validation bằng cách tách

// Quy tac 1: test truoc
it('tu choi email sai dinh dang', () => {
  expect(registerUser('khong-at', '12345678', '12345678')).toBe(false);
});
it('tu choi mat khau qua ngan', () => {
  expect(registerUser('a@b.com', '1234567', '1234567')).toBe(false);
});
it('tu choi mat khau khong khop', () => {
  expect(registerUser('a@b.com', '12345678', 'xxxxxxxx')).toBe(false);
});

// Quy tac 2: xoa lap - tach validation thanh ham rieng
const MIN_PASSWORD_LENGTH = 8; // replace magic number

function isValidEmail(email: string): boolean {
  return email.includes('@');
}
function isStrongEnough(password: string): boolean {
  return password.length >= MIN_PASSWORD_LENGTH;
}
function passwordsMatch(pw: string, pw2: string): boolean {
  return pw === pw2;
}

Sau quy tắc 3 & 4 - ý định rõ, không có abstraction thừa

// Quy tac 3: dien dat ro - ham dieu phoi doc nhu van xuoi
function registerUser(email: string, password: string, confirmPassword: string): boolean {
  if (!isValidEmail(email)) return false;
  if (!isStrongEnough(password)) return false;
  if (!passwordsMatch(password, confirmPassword)) return false;
  saveNewUser(email, password);
  return true;
}

function saveNewUser(email: string, password: string): void {
  db.insert({ email, password: hashPw(password), createdAt: new Date() });
}

// Quy tac 4: khong them IUserRepository, UserFactory, ValidationStrategy...
// khi chi co mot implementation va chua co yeu cau mo rong cu the
  • Quy tắc 1 tạo lưới an toàn; quy tắc 2+3 làm code dễ đọc và bảo trì; quy tắc 4 giữ code khỏi phình to.
  • Bốn vòng lọc này có thể áp dụng cho bất kỳ đoạn code nào, từ hàm nhỏ đến module lớn.
  • Kết quả cuối: code chạy đúng, không lặp, đọc rõ ý định, và không có gì thừa - đó là "thiết kế đơn giản".

Bốn quy tắc thiết kế đơn giản là bộ lọc chung - áp dụng được ở mọi quy mô. Khi dự án lớn lên và đội có nhiều người, cần công cụ tinh chỉnh hơn cho thiết kế hướng đối tượng: đó là lúc SOLID phát huy tác dụng.

SOLID không thay thế bốn quy tắc - nó bổ sung thêm hướng dẫn cụ thể: tách trách nhiệm (SRP), mở rộng không sửa (OCP), thay thế lớp con (LSP), interface nhỏ chuyên biệt (ISP), phụ thuộc abstraction (DIP). Mỗi nguyên tắc là câu trả lời cho một kiểu vấn đề hay gặp khi áp dụng quy tắc 2 và 3 ở quy mô lớn.

Lộ trình tiếp theo

Khoá tiếp theo trong lộ trình "Code sạch & kiến trúc" là SOLID - năm nguyên tắc thiết kế hướng đối tượng với ví dụ TypeScript tương tác. Bốn quy tắc vừa học là nền tảng giúp hiểu SOLID không phải là luật trừu tượng mà là câu trả lời thực tế cho "làm sao xoá lặp và diễn đạt rõ ở quy mô lớn?".

Còn một bài nữa trong khoá này: Dự án refactor (bài 12) - ba thử thách refactor thực tế bằng TypeScript. Đó là nơi mèo con dùng tất cả: nhận diện smell, chọn phép refactor, viết characterization test, và áp dụng bốn quy tắc lần lượt để biến code "bẩn" thành sạch từng bước có thể kiểm chứng.

  • Bốn quy tắc: bộ lọc chung cho mọi quy mô - hỏi lần lượt trước khi ship bất kỳ đoạn code nào.
  • SOLID: công cụ tinh chỉnh thêm cho thiết kế hướng đối tượng trong dự án lớn - khoá kế tiếp.
  • Emergent design là thực hành, không phải lý thuyết: test → refactor → lặp lại là cơ chế.

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

Bốn quy tắc là bộ lọc đơn giản để hỏi "thiết kế này đủ tốt chưa?" - ưu tiên rõ ràng và áp dụng được cho bất kỳ đoạn code nào. SOLID đi sâu hơn vào năm nguyên tắc cụ thể cho thiết kế hướng đối tượng trong dự án lớn. Hai bộ bổ trợ nhau: bốn quy tắc là nền tảng, SOLID là công cụ tinh chỉnh.

Không phải viết ít nhất - mà tránh trừu tượng hoá sớm không cần thiết. Mỗi class, interface, method nên tồn tại vì một lý do cụ thể đang giải quyết. Nếu tạo interface vì "tương lai có thể cần" nhưng hiện tại chỉ có một implementation, đó là complexity không ai trả tiền. Quy tắc 4 là lực cân bằng với quy tắc 3 (diễn đạt rõ): không phải trừu tượng hoá vô điều kiện.

Không phải thế. Thiết kế trước ở mức cao (kiến trúc tổng thể, ranh giới module, giao thức) vẫn cần thiết - đặc biệt trong dự án nhiều người. Emergent design nói về THIẾT KẾ CỤC BỘ: đừng cố vẽ sẵn mọi class và interface trước khi viết dòng code đầu tiên. Viết test, viết code đủ để test xanh, rồi refactor xoá lặp và diễn đạt rõ - thiết kế tốt sẽ nổi lên từ đó.

Theo thứ tự ưu tiên thì thực ra chỉ cần hỏi lần lượt: code có chạy hết test không? Có trùng lặp không? Ý định có rõ không? Có gì thừa không? Mỗi lần ngồi review hoặc refactor, hỏi bốn câu đó lần lượt là bộ lọc đủ tốt. Không phải sửa hết trong một lần - tuần tự theo thứ tự ưu tiên.

DRY tốt khi hai đoạn lặp là CÙNG MỘT Ý ĐỊNH - sửa một bản thì muốn sửa cả bản kia. Nhưng có loại "trùng lặp bề ngoài" mà thực ra hai đoạn sẽ phát triển theo hướng khác nhau - gộp vội tạo ra coupling không cần thiết. Hỏi "nếu logic nghiệp vụ này đổi, tôi muốn đổi cả hai bản không?" trước khi gộp.

Rất liên quan. Dự án cuối (bài 12) là một chuỗi thử thách refactor: nhận code "bẩn", viết characterization test, rồi áp dụng lần lượt: xoá smell, xoá lặp, diễn đạt rõ, loại trừ thừa. Bốn quy tắc là kim chỉ nam hướng dẫn mỗi bước refactor trong dự án đó.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Thứ tự ưu tiên đúng của bốn quy tắc thiết kế đơn giản (Kent Beck) là gì?

  1. 1

    Tự phát biểu bốn quy tắc

    Không nhìn bài, viết lại bốn quy tắc thiết kế đơn giản theo thứ tự ưu tiên bằng lời của mèo con. Mỗi quy tắc một câu giải thích ngắn.

    Hoàn thành khi: Bốn quy tắc đúng thứ tự và mỗi quy tắc có một câu giải thích "ý nghĩa của nó trong thực tế là..."

  2. 2

    Kiểm tra quy tắc 1 - chạy hết test

    Lấy một module hoặc class trong dự án. Chạy test và ghi lại: bao nhiêu phần trăm hành vi quan trọng được phủ? Phần nào chưa có test? Viết thêm ít nhất 2 test còn thiếu.

    Hoàn thành khi: Hai test mới phủ hành vi chưa được kiểm tra; tất cả test xanh; ghi chú những phần KHÔNG thể test (và tại sao - vd side effect, I/O).

  3. 3

    Tìm và xoá trùng lặp (quy tắc 2)

    Tìm một cặp hàm hoặc đoạn code có logic lặp nhau trong dự án. Áp dụng DRY: tách phần chung ra hàm/module riêng. Chạy test sau khi tách.

    Hoàn thành khi: Phần logic chung nằm ở một chỗ; hai chỗ cũ gọi chung; test xanh; commit message mô tả "xoá trùng lặp ở..."

  4. 4

    Cải thiện diễn đạt (quy tắc 3)

    Chọn một hàm hoặc class khó đọc. Áp dụng ít nhất 2 trong số: rename, extract function, replace magic number - để tên và cấu trúc nói rõ ý định hơn.

    Hoàn thành khi: Code trước và sau; đọc phần sau mà không cần nhìn vào thân hàm vẫn biết hàm làm gì; test xanh.

  5. 5

    Loại bỏ phần thừa (quy tắc 4)

    Tìm một interface, abstract class, hoặc helper chỉ có một implementation và không có kế hoạch mở rộng. Xoá lớp trừu tượng đó, dùng thẳng implementation. Ghi lại lý do.

    Hoàn thành khi: Lớp trừu tượng bị xoá; code compile và test xanh; ghi chú "lớp này tồn tại vì X - X không còn cần nữa vì Y".

  6. 6

    Vòng lặp emergent design

    Viết một tính năng nhỏ mới (hoặc một module mới) bằng cách: (1) viết test trước, (2) viết code tối thiểu để test xanh, (3) refactor theo bốn quy tắc. Lặp ít nhất 3 vòng.

    Hoàn thành khi: Ít nhất 3 commit: mỗi commit test xanh và có refactor nhỏ; commit message ghi rõ bước nào trong vòng lặp; tính năng hoạt động đúng.