Soldemy
0%

SOLID trong Java Spring Boot

Thiết kế tốt và chi phí của thay đổi

26 phút đọc

Thiết kế tốt và chi phí của thay đổi

Mọi lập trình viên đều viết được code chạy đúng. Điều phân biệt một hệ thống doanh nghiệp sống sót qua nhiều năm với một hệ thống trở thành gánh nặng không nằm ở chỗ code có chạy hay không — mà ở chỗ sửa nó tốn bao nhiêu. Một tính năng mới đáng lẽ mất nửa ngày lại tốn ba ngày; một sửa lỗi tưởng vô hại lại làm hỏng hai chỗ khác. Khác biệt đó không phải do lập trình viên kém, mà do thiết kế của hệ thống.

Chương này chưa dạy nguyên lý SOLID nào cả. Mục tiêu của nó là trang bị cho bạn cách nhìn và bộ từ vựng mà toàn bộ phần còn lại của cuốn sách dựa vào: chi phí thay đổi (cost of change), coupling, cohesion, separation of concerns, connascence, cùng những dấu hiệu có tên gọi của một thiết kế đang xuống cấp. Khi đã nhìn phần mềm qua lăng kính chi phí thay đổi, năm nguyên lý SOLID sẽ không còn là năm quy tắc rời rạc phải học thuộc, mà hiện ra như năm công cụ cụ thể cùng giải quyết một vấn đề. Chúng ta sẽ dùng một hệ thống LMS (nền tảng học trực tuyến) làm ví dụ xuyên suốt, và một class cụ thể — EnrollmentManager — làm ví dụ điển hình được cải thiện dần qua từng chương.


Một class ban đầu gọn rồi suy giảm qua thay đổi

Hãy theo dõi một tình huống có thật ở gần như mọi hệ thống. Trong nền tảng LMS của chúng ta, ở tuần đầu tiên có một class tên EnrollmentManager lo việc ghi danh học viên vào khóa học. Lúc đó nó khoảng bốn mươi dòng: nhận yêu cầu ghi danh, kiểm tra học viên đã đăng ký chưa, lưu vào database. Gọn gàng, dễ đọc, ai nhìn cũng hiểu.

Rồi yêu cầu đến, từng cái một, đúng như thực tế. "Ghi danh xong thì gửi email xác nhận." Thêm vài dòng vào EnrollmentManager. "Khóa trả phí thì phải tính học phí và áp mã giảm giá." Thêm nữa. "Học viên hoàn thành thì sinh chứng chỉ PDF." Thêm nữa. Mỗi yêu cầu đơn lẻ đều nhỏ và hợp lý; không ai thấy có gì sai khi thêm dăm dòng vào một class có sẵn. Đó chính là cách mọi codebase xuống cấp: không phải bằng một quyết định tồi lớn, mà bằng hàng trăm quyết định nhỏ đều "có vẻ ổn". Sau hai năm, EnrollmentManager dài bốn trăm dòng và phụ thuộc vào database, dịch vụ email, thư viện sinh PDF, bảng giá, và logic khuyến mãi.

Vấn đề lộ ra vào ngày có một yêu cầu tưởng chừng tầm thường: hỗ trợ một loại khóa học mới. Để làm điều đó, lập trình viên phải mở EnrollmentManager ra sửa. Nhưng vì logic tính phí, gửi email và sinh chứng chỉ đều nằm chung trong class này, một thay đổi ở nhánh tính phí vô tình làm sai luồng gửi email cho các khóa cũ. Một yêu cầu nhỏ kéo theo một lỗi ở chỗ tưởng như chẳng liên quan. Việc đáng lẽ mất nửa ngày trở thành ba ngày dò lỗi.

Ghi chú

Câu hỏi định hướng cả chương: vì sao cùng một thay đổi lại rẻ trong codebase này nhưng đắt trong codebase kia? Trả lời được câu này là hiểu được toàn bộ lý do SOLID tồn tại.

Chi phí của thay đổi

Trong suốt vòng đời một hệ thống doanh nghiệp, phần lớn công sức không nằm ở lần viết đầu tiên mà ở vô số lần sửa và mở rộng về sau: thêm tính năng, sửa lỗi, thích ứng với yêu cầu nghiệp vụ thay đổi. Vì vậy thước đo thực tế của một thiết kế không phải "viết ra nhanh không" mà là "thay đổi về sau rẻ hay đắt". Một thiết kế tốt giữ cho chi phí của mỗi thay đổi tương lai ở mức thấp và ổn định; một thiết kế tồi khiến chi phí đó tăng dần cho tới khi mỗi tính năng mới đều là một cuộc vật lộn.

Martin Fowler mô tả hiện tượng này bằng Design Stamina Hypothesis. Hình dung hai đường biểu diễn khối lượng tính năng bàn giao theo thời gian: một đường cho codebase bỏ qua thiết kế, một đường cho codebase đầu tư thiết kế. Ban đầu, đường bỏ qua thiết kế nhỉnh hơn vì không tốn công cho cấu trúc. Nhưng hai đường cắt nhau tại một điểm gọi là design payoff line; sau điểm đó, codebase có thiết kế tốt vượt lên và ngày càng bỏ xa, vì nó không phải trả giá cho sự rối rắm tích tụ. Fowler nói thẳng rằng đây là một giả thuyết dựa trên kinh nghiệm chứ không phải số liệu đo đạc — nhưng bất kỳ ai từng bảo trì một hệ thống cũ đều nhận ra nó đúng với trải nghiệm của mình.

Hãy làm cho khái niệm "chi phí lan ra" cụ thể hơn bằng cách truy vết một thay đổi trên EnrollmentManager. Yêu cầu mới: "thêm loại khuyến mãi BLACKFRIDAY giảm 70%". Trong một thiết kế đã tách concern, việc này chỉ là thêm một class mới cho quy tắc giảm giá, không ảnh hưởng tới phần còn lại. Nhưng trong EnrollmentManager, bạn phải: mở method enroll, nối thêm một nhánh vào chuỗi if-else khuyến mãi, đọc lại cả method để chắc nhánh mới không phá vỡ logic lưu trữ hay gửi email nằm ngay bên dưới, rồi kiểm thử — mà muốn kiểm thử thì phải dựng cả database và mail server vì logic gắn chặt với chúng. Một thay đổi khái niệm rất nhỏ ("thêm một con số phần trăm") biến thành một loạt thao tác rủi ro. Đó chính là chi phí thay đổi mà thiết kế kém áp lên bạn.


Bốn dấu hiệu của một thiết kế kém

Robert C. Martin, trong cuốn sách đặt nền cho SOLID, chỉ ra bốn dấu hiệu đặc trưng của một thiết kế kém. Chúng đáng thuộc nằm lòng vì đây chính là những gì bạn sẽ cảm nhận được khi làm việc với một codebase tồi, trước cả khi gọi tên được nguyên nhân kỹ thuật. Cả bốn đều là biểu hiện của cùng một gốc rễ: coupling quá cao.

Rigidity (tính cứng nhắc): hệ thống khó thay đổi vì mỗi thay đổi nhỏ lại kéo theo một chuỗi thay đổi bắt buộc ở nhiều chỗ khác. Bạn định sửa một thứ, nhưng để sửa được nó phải sửa thêm năm chỗ nữa. Với EnrollmentManager, đổi cách tính phí buộc phải sửa một method vốn cũng chứa logic lưu trữ và email.

Fragility (tính dễ vỡ): thay đổi ở một chỗ làm vỡ những chỗ khác không liên quan về mặt khái niệm. Đây đúng là lỗi email ở đầu chương: sửa nhánh tính phí mà luồng gửi email lại hỏng. Hệ thống dễ vỡ khiến lập trình viên e ngại chỉnh sửa, và sự e ngại đó làm chậm cả nhóm.

Immobility (khó tái sử dụng): có những phần lẽ ra dùng lại được ở nơi khác, nhưng không tách ra được vì chúng gắn quá chặt với những thứ xung quanh. Logic "kiểm tra học viên đã ghi danh chưa" trong EnrollmentManager về lý thuyết hữu ích cho nhiều tính năng, nhưng vì nó nằm lẫn trong một method dùng JDBC thô và gọi email, không ai tách nó sang nơi khác được.

Viscosity (độ nhớt): khi lựa chọn giữ thiết kế tốt tốn công hơn lựa chọn làm tăng technical debt, áp lực giao hàng sẽ đẩy team về phía thứ hai. Nếu thêm một class đúng boundary đòi hỏi sửa cấu hình phức tạp còn chèn một nhánh if-else vào class cũ lại nhanh hơn nhiều, codebase đang khuyến khích cấu trúc tiếp tục suy giảm. Viscosity cao là một lý do God object tiếp tục lớn dần.

Bốn dấu hiệu — rigidity, fragility, immobility, viscosity — không phải bốn vấn đề riêng biệt. Chúng là bốn cách khác nhau mà coupling cao và cohesion thấp biểu hiện ra. Toàn bộ SOLID tồn tại để khắc phục bốn dấu hiệu này tận gốc.


Coupling — mức độ phụ thuộc lẫn nhau

Coupling là mức độ một thành phần phụ thuộc vào thành phần khác. Khái niệm này được Larry Constantine đưa ra từ cuối thập niên 1960 trong Structured Design và tới nay vẫn là thước đo cốt lõi nhất của thiết kế. Khi hai class gắn chặt tới mức thay đổi cái này gần như chắc chắn buộc phải sửa cái kia, ta có tight coupling. Khi chúng tương tác qua một điểm giao tiếp ổn định và ít biết về chi tiết bên trong của nhau, ta có loose coupling. Mục tiêu thiết kế gần như luôn là loose coupling.

Điều nhiều người không được dạy là coupling có nhiều mức độ, không phải chỉ "có" hay "không". Structured Design xếp các loại coupling từ tệ nhất đến tốt nhất, và biết thang này giúp bạn nhận ra mình đang ở đâu:

  • Content coupling (tệ nhất): một class truy cập hoặc sửa trực tiếp phần bên trong của class khác. Ví dụ đọc thẳng một field private qua reflection, hay dựa vào chi tiết cài đặt nội bộ của class khác.

  • Common coupling: nhiều class cùng phụ thuộc vào một trạng thái toàn cục có thể thay đổi (biến static dùng chung, singleton mang state). Đổi trạng thái đó làm ảnh hưởng mọi nơi.

  • Control coupling: một class điều khiển luồng chạy bên trong class khác bằng một cờ (flag). Ví dụ process(order, true) với true nghĩa là "gửi email luôn" — bên gọi phải biết nội tình bên bị gọi.

  • Stamp coupling: truyền cả một object lớn trong khi bên nhận chỉ cần vài field của nó, khiến bên nhận phụ thuộc vào toàn bộ cấu trúc object.

  • Data coupling (tốt nhất): các thành phần chỉ trao đổi đúng dữ liệu cần thiết qua tham số đơn giản. Đây là mức ta hướng tới.

Trong EnrollmentManager, tight coupling xuất hiện ở chỗ nó gọi trực tiếp tới thư viện gửi email cụ thể, tới cách sinh PDF cụ thể, tới cách truy cập database cụ thể. Nó không chỉ dùng những thứ đó mà còn biết quá nhiều về chúng. Đoạn code nhỏ dưới đây làm rõ khác biệt giữa tight và loose coupling ở dạng thu gọn nhất.

JavaScript
// TIGHT COUPLING: EnrollmentService tự tạo và phụ thuộc vào một implementation cụ thể
public class EnrollmentService {
    private final StripePaymentClient payment = new StripePaymentClient(); // gắn chặt Stripe

    public void enroll(Long studentId, Long courseId) {
        // ... muốn đổi sang cổng thanh toán khác là phải sửa chính class này
        payment.charge(studentId, courseId);
    }
}
JavaScript
// LOOSE COUPLING: phụ thuộc vào một abstraction, implementation được đưa từ ngoài vào
public interface PaymentGateway {
    void charge(Long studentId, Long courseId);
}

public class EnrollmentService {
    private final PaymentGateway payment; // chỉ biết "cổng thanh toán", không biết là Stripe hay gì

    public EnrollmentService(PaymentGateway payment) { // được tiêm từ ngoài
        this.payment = payment;
    }

    public void enroll(Long studentId, Long courseId) {
        payment.charge(studentId, courseId);
    }
}

Hai đoạn trên có cùng hành vi, nhưng chi phí thay đổi khác hẳn. Ở bản tight, đổi cổng thanh toán buộc phải mở EnrollmentService. Ở bản loose, EnrollmentService không cần biết cổng nào đang được dùng, nên đổi cổng chỉ là đưa vào một implementation khác — và như bạn sẽ thấy ở Chương 2, đây chính là việc mà Spring làm tự động. Đừng lo nếu chi tiết còn mơ hồ; đây mới là bức tranh tổng thể, các chương sau sẽ đào sâu.


Cohesion — mức độ tập trung của một thành phần

Cohesion là mức độ các phần bên trong một class cùng phục vụ một mục đích. Một class có high cohesion khi mọi method của nó cùng xoay quanh một trách nhiệm rõ ràng; ngược lại, low cohesion xảy ra khi một class gom những việc chẳng liên quan gì đến nhau. Cũng như coupling, cohesion là một thang bậc chứ không phải công tắc bật/tắt. Structured Design mô tả một dải từ thấp đến cao: coincidental (gom ngẫu nhiên, chẳng theo lý do nào), logical, temporal (gom vì cùng chạy tại một thời điểm), procedural, communicational, sequential, và cao nhất là functional cohesion — mọi phần cùng phục vụ đúng một nhiệm vụ. Mục tiêu của chúng ta là functional cohesion.

EnrollmentManager nằm ở đáy của thang này. Việc ghi danh, việc tính tiền, việc gửi email và việc sinh PDF là bốn nhóm nhiệm vụ khác hẳn nhau, chỉ tình cờ nằm chung một chỗ vì tiện tay thêm vào. Ví dụ nhỏ sau minh họa low cohesion ở dạng rõ nhất: một class tiện ích gom những việc không liên quan.

JavaScript
// LOW COHESION: các method không liên quan bị gom chung
public class CourseUtils {
    public double calculateFee(Course course) { /* logic học phí */ return 0; }
    public String formatStartDate(Course course) { /* định dạng ngày */ return ""; }
    public byte[] renderCertificate(Long studentId) { /* sinh PDF */ return new byte[0]; }
}

Ba method trên thay đổi vì ba lý do hoàn toàn khác nhau — thay đổi bảng giá, thay đổi định dạng hiển thị, thay đổi mẫu chứng chỉ — nhưng lại bị buộc sống chung trong một class. Tách chúng thành FeeCalculator, CourseDateFormatterCertificateRenderer sẽ cho mỗi class một lý do thay đổi duy nhất, tức đẩy cohesion lên mức functional. Đây chính là tinh thần của Single Responsibility Principle mà Chương 3 sẽ trình bày kỹ.

Cần nhấn mạnh một điểm người mới rất hay nhầm: coupling và cohesion là hai trục độc lập, không phải hai đầu của cùng một thước đo. Coupling nói về quan hệ giữa các thành phần; cohesion nói về sự tập trung bên trong một thành phần. Một hệ thống lý tưởng có low coupling giữa các thành phần và high cohesion bên trong mỗi thành phần. Hai đại lượng này thường đi cùng nhau: khi bạn gom đúng những thứ liên quan vào một chỗ (tăng cohesion), bạn tự nhiên cắt bớt các phụ thuộc chạy lung tung giữa các class (giảm coupling). Gần như mọi kỹ thuật trong cuốn sách này, xét cho cùng, là cách để kéo cohesion lên và đẩy coupling xuống.


Separation of Concerns và Connascence

Separation of Concerns (SoC) là nguyên tắc chia một chương trình thành các phần tách biệt, mỗi phần lo một mối quan tâm (concern) riêng. Một concern là một khía cạnh của hệ thống: truy cập dữ liệu là một concern, logic nghiệp vụ là một concern, trình bày kết quả là một concern khác. EnrollmentManager vi phạm SoC vì nó trộn cả bốn concern vào một class. Ở Chương 2 bạn sẽ thấy Spring Boot khuyến khích SoC một cách rất tự nhiên qua kiến trúc phân lớp Controller — Service — Repository, mỗi lớp một concern.

Một cách nhìn sâu hơn về phụ thuộc, do Meilir Page-Jones đề xuất, là connascence: mức độ và loại phụ thuộc khiến thay đổi ở một chỗ buộc phải thay đổi ở chỗ khác để chương trình vẫn đúng. Connascence cho ta một ngôn ngữ tinh vi hơn "coupling" đơn thuần vì nó phân loại phụ thuộc và cho biết phụ thuộc đó xảy ra lúc biên dịch hay lúc chạy. Connascence tĩnh (thấy được lúc biên dịch) gồm phụ thuộc theo tên, theo kiểu, theo quy ước, theo thuật toán, theo vị trí; connascence động (chỉ lộ lúc chạy) gồm phụ thuộc theo thứ tự thực thi, theo thời điểm, theo giá trị, và theo danh tính. Càng về sau càng khó phát hiện và càng nguy hiểm. Chương 9 sẽ dùng connascence như công cụ đánh giá thiết kế ở cấp kiến trúc; ở đây chỉ cần ghi nhớ: coupling không phải một con số duy nhất mà có nhiều dạng với mức độ nguy hiểm khác nhau.


Khi thiết kế xuống cấp: bốn khái niệm cần gọi tên

Khi coupling tăng và cohesion giảm mà không ai xử lý, hệ thống bắt đầu xuất hiện những dấu hiệu có tên gọi riêng. Gọi đúng tên chúng giúp cả nhóm nói cùng một ngôn ngữ khi review code.

Code smell là một dấu hiệu bề mặt cho thấy có vấn đề thiết kế sâu hơn bên dưới. Bản thân smell không phải bug — code vẫn chạy đúng — nhưng nó báo hiệu chỗ dễ sinh lỗi và khó bảo trì. Một class dài bất thường, một chuỗi if-else phình to theo từng loại, một method nhận quá nhiều tham số đều là smell. Smell là tín hiệu để dừng lại và cân nhắc refactor, không phải mệnh lệnh phải sửa ngay bằng mọi giá.

God object là một smell cụ thể và nghiêm trọng: một class ôm quá nhiều trách nhiệm và biết quá nhiều về phần còn lại của hệ thống. EnrollmentManager chính là một God object. Nó là tâm điểm mà mọi thay đổi đều phải đi qua, và vì thế là nơi lỗi tập trung nhiều nhất.

Technical debt (nợ kỹ thuật) là ẩn dụ do Ward Cunningham đưa ra: chọn một giải pháp nhanh-nhưng-cẩu-thả hôm nay giống như vay nợ, và bạn sẽ trả lãi bằng thời gian phụ trội cho mỗi thay đổi về sau. Nếu thêm một tính năng mất hai ngày trong codebase sạch nhưng bốn ngày trong codebase rối, thì hai ngày chênh lệch đó chính là tiền lãi bạn đang trả. Nợ kỹ thuật không xấu tuyệt đối — đôi khi vay nợ có chủ đích để kịp ra mắt là hợp lý — nhưng nợ không được quản lý sẽ dồn lại tới mức không trả nổi.

Refactoring là hành động cải thiện cấu trúc bên trong của code mà không thay đổi hành vi quan sát được từ bên ngoài. Đây là cách chính để trả nợ kỹ thuật: tách một class quá lớn, đặt lại tên cho rõ nghĩa, gỡ một phụ thuộc chạy thẳng. Điều kiện tiên quyết để refactor an toàn là có test bảo vệ hành vi — vì nếu không có test, bạn không thể biết mình đã đổi cấu trúc mà không vô tình đổi hành vi. Đây là một lý do nữa vì sao testability, chủ đề của Chương 8, gắn chặt với thiết kế tốt.


Thiết kế tốt trông như thế nào

Đến đây ta đã phân tích khá nhiều về cái xấu; cần một tầm nhìn rõ ràng về cái tốt để hướng tới, nếu không mọi nguyên lý sau sẽ trở thành việc "tránh lỗi" thay vì "xây đúng". Một thiết kế tốt không phải là thiết kế nhiều tầng lớp hay nhiều interface nhất, mà là thiết kế mà ở đó thay đổi rẻ và an toàn.

Cụ thể, trong một thiết kế tốt: mỗi thành phần có một trách nhiệm rõ ràng và một lý do duy nhất để thay đổi (high cohesion); các thành phần giao tiếp qua những abstraction ổn định và biết về nhau càng ít càng tốt (low coupling); một thay đổi nghiệp vụ điển hình chỉ chạm vào một hoặc vài chỗ khu trú, không lan ra toàn hệ thống; có thể kiểm thử từng phần một cách độc lập mà không cần dựng cả hệ thống; và một người mới có thể đọc một phần mà hiểu được nó mà không phải nạp toàn bộ codebase vào đầu. Năm nguyên lý SOLID, mỗi nguyên lý một cách, đều đẩy thiết kế về phía trạng thái này.


SOLID: năm công cụ cho cùng một mục tiêu

SOLID là tên viết tắt của năm nguyên lý thiết kế hướng đối tượng. Các nguyên lý này được Robert C. Martin trình bày và hệ thống hóa từ cuối thập niên 1990 đến đầu 2000; cái tên "SOLID" cùng thứ tự chữ cái quen thuộc là do Michael Feathers đề xuất sau đó như một cách ghi nhớ. Điều quan trọng cần nắm ngay từ đầu: cả năm nguyên lý không phải năm quy tắc độc lập, mà là năm cách khác nhau để đạt cùng một mục tiêu — giảm coupling, tăng cohesion, và nhờ đó giữ chi phí thay đổi ở mức thấp.

  • S — Single Responsibility: mỗi class chỉ nên có một lý do để thay đổi. Đây trực tiếp là nguyên lý về cohesion: gom đúng một trách nhiệm vào một chỗ.

  • O — Open/Closed: mở để mở rộng, đóng với sửa đổi. Cho phép thêm hành vi mới bằng cách thêm code, không sửa code cũ đã chạy ổn — trực tiếp chống lại rigidity và fragility.

  • L — Liskov Substitution: subtype phải thay thế được supertype mà không phá vỡ correctness của client. Nguyên lý giữ cho quan hệ subtyping không tạo ra những ràng buộc hành vi bất ngờ.

  • I — Interface Segregation: đừng ép client phụ thuộc vào method nó không dùng. Là nguyên lý cohesion áp cho interface, đồng thời cắt bớt coupling thừa.

  • D — Dependency Inversion: phụ thuộc vào abstraction, không phụ thuộc vào chi tiết cụ thể. Là cơ chế chính để đạt loose coupling, và chính là nền tảng mà Spring được xây quanh.

Cuốn sách này trình bày năm nguyên lý theo đúng thứ tự chữ cái S-O-L-I-D. Đây là lựa chọn có chủ đích: SRP mở đầu vì nó đơn giản nhất và chỉ nói về việc đặt code ở đâu, chưa cần tới abstraction; DIP đặt cuối vì nó là cao trào nối thẳng với cơ chế dependency injection của Spring mà bạn sẽ học ở Chương 2. Cần lưu ý rằng trong cuốn sách gốc của Robert C. Martin, thứ tự lại là SRP-OCP-LSP-DIP-ISP. Chúng ta chọn thứ tự phổ biến hơn vì lý do sư phạm, không phải vì mặc định.


Cái giá thật sự ở môi trường doanh nghiệp

Ở trường, một bài tập thường kết thúc ngay khi code chạy đúng, nên "thiết kế" dễ bị xem là thứ trang trí. Ở doanh nghiệp thì ngược lại: code được viết một lần nhưng bị đọc, sửa và mở rộng hàng trăm lần, bởi nhiều người, qua nhiều năm, thường là những người không hề viết ra nó ban đầu. Trong bối cảnh đó, tác hại của tight coupling và low cohesion không còn trừu tượng mà quy ra thành những chi phí đo đếm được.

Thứ nhất là khả năng test. Một EnrollmentManager gọi thẳng database, email và thư viện PDF gần như không thể viết unit test, vì muốn test logic ghi danh bạn buộc phải có cả một database thật và một server mail thật. Code khó test là một trong những tín hiệu sớm và đáng tin cậy nhất của thiết kế kém — mỗi khi thấy "cái này khó test quá", hãy coi đó là tín hiệu cảnh báo về coupling, chứ không phải vấn đề của việc viết test.

Thứ hai là chi phí onboard. Một người mới vào nhóm phải đọc trọn bốn trăm dòng đan xen nhiều concern chỉ để hiểu một thay đổi nhỏ ảnh hưởng tới đâu. Thứ ba là rủi ro sự cố: khi mọi thứ dồn vào một điểm, một sửa đổi ở nhánh này làm hỏng nhánh khác, đúng như lỗi email ở đầu chương. Thứ tư là tốc độ của cả nhóm giảm dần theo thời gian — chính là phần bên phải của đường cong Design Stamina, và là biểu hiện gộp của bốn dấu hiệu rigidity, fragility, immobility, viscosity.

Đây cũng là sứ mệnh của cuốn sách: bắc cầu giữa kiến thức học thuật và kỹ năng làm việc thực tế. Biết cú pháp Java và định nghĩa OOP là điều kiện cần; biết cách sắp xếp code sao cho hệ thống chịu được thay đổi qua nhiều năm và nhiều người mới là thứ phân biệt một lập trình viên doanh nghiệp.


Trường hợp điển hình: EnrollmentManager dưới dạng code

Đến đây ta nhìn thẳng vào toàn bộ trường hợp này. Dưới đây là EnrollmentManager ở trạng thái đã xuống cấp. Hãy đọc với con mắt vừa trang bị: tìm chỗ tight coupling, chỉ ra các concern bị trộn, nhận diện bốn dấu hiệu thiết kế kém, và tự hỏi "muốn test riêng logic ghi danh thì phải làm sao".

JavaScript
@Service
public class EnrollmentManager {

    private final DataSource dataSource;      // truy cập DB trực tiếp
    private final JavaMailSender mailSender;  // gắn chặt nhà cung cấp email

    public EnrollmentManager(DataSource dataSource, JavaMailSender mailSender) {
        this.dataSource = dataSource;
        this.mailSender = mailSender;
    }

    public void enroll(Long studentId, Long courseId, String promoCode) {
        // 1. Concern: logic nghiệp vụ — kiểm tra đã ghi danh chưa (JDBC thô)
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement check = conn.prepareStatement(
                "SELECT COUNT(*) FROM enrollments WHERE student_id = ? AND course_id = ?");
            check.setLong(1, studentId);
            check.setLong(2, courseId);
            ResultSet rs = check.executeQuery();
            rs.next();
            if (rs.getInt(1) > 0) {
                throw new IllegalStateException("Đã ghi danh khóa này rồi");
            }

            // 2. Concern: tính học phí + áp khuyến mãi (business rule trộn thẳng vào)
            double fee = loadCourseFee(conn, courseId);
            if ("WELCOME10".equals(promoCode)) {
                fee = fee * 0.9;
            } else if ("VIP50".equals(promoCode)) {
                fee = fee * 0.5;
            }

            // 3. Concern: lưu trữ (JDBC thô, gắn chặt schema)
            PreparedStatement insert = conn.prepareStatement(
                "INSERT INTO enrollments(student_id, course_id, fee) VALUES (?, ?, ?)");
            insert.setLong(1, studentId);
            insert.setLong(2, courseId);
            insert.setDouble(3, fee);
            insert.executeUpdate();

            // 4. Concern: gửi email xác nhận (gắn chặt JavaMailSender)
            SimpleMailMessage msg = new SimpleMailMessage();
            msg.setTo(loadStudentEmail(conn, studentId));
            msg.setSubject("Xác nhận ghi danh");
            msg.setText("Bạn đã ghi danh thành công. Học phí: " + fee);
            mailSender.send(msg);

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    private double loadCourseFee(Connection conn, Long courseId) throws SQLException { /* ... */ return 0; }
    private String loadStudentEmail(Connection conn, Long studentId) throws SQLException { /* ... */ return ""; }
}

Ghi chú

Phân tích trường hợp: - Bốn concern (nghiệp vụ, tính phí, lưu trữ, thông báo) nằm chung một method — low cohesion, vi phạm SRP (Chương 3). - Thêm một kiểu khuyến mãi mới buộc phải mở method và nối thêm nhánh else-if — rigidity + viscosity, sẽ được OCP (Chương 4) xử lý. - Gọi thẳng `DataSource` và `JavaMailSender` — tight coupling khiến logic không thể unit test độc lập; DIP (Chương 7) và Chương 8 sẽ tháo gỡ. - Sửa nhánh tính phí có thể làm hỏng luồng email ngay bên dưới — fragility.

Bạn có thể đã muốn refactor ngay. Hãy khoan. Cố ý chưa đưa ra bản "sau" hoàn chỉnh ở đây, vì mỗi nguyên lý trong các chương tiếp theo sẽ xử lý một khía cạnh của chính class này: Chương 3 tách các trách nhiệm theo Single Responsibility, Chương 4 thay chuỗi if-else khuyến mãi bằng thiết kế Open/Closed, Chương 7 đảo chiều phụ thuộc vào database và email theo Dependency Inversion, Chương 8 bổ sung test cho toàn bộ. Đến cuối sách, EnrollmentManager sẽ được tái cấu trúc thành một thiết kế sạch, và bạn sẽ thấy rõ từng nguyên lý đã đóng góp phần nào. Đây là ví dụ xuyên suốt của chúng ta.


Những hiểu lầm phổ biến

Trước khi khép chương, cần gỡ bốn hiểu lầm hay gặp, vì chúng sẽ làm lệch cách bạn tiếp nhận các chương sau.

  • "Clean code chỉ là chuyện thẩm mỹ." Không. Cấu trúc code quyết định chi phí thay đổi — một vấn đề kinh tế, không phải sở thích cá nhân.

  • "Coupling với cohesion là một." Không. Đó là hai trục độc lập: coupling nói về quan hệ giữa các thành phần, cohesion nói về sự tập trung bên trong một thành phần.

  • "Càng nhiều class và interface thì càng SOLID." Không. Thêm abstraction không cần thiết chính là over-engineering, cũng làm tăng chi phí thay đổi theo kiểu khác. SOLID là công cụ, không phải cái đích tự thân — điều này ta sẽ nhắc lại ở mỗi chương khi bàn về đánh đổi.

  • "Thiết kế để sau tính, chạy được đã." Chỉ đúng khi dự án nằm bên trái design payoff line và chắc chắn không sống lâu. Với hệ thống doanh nghiệp, bỏ qua thiết kế gần như luôn khiến bạn giao hàng chậm hơn về sau.


Chương này nối với phần còn lại của sách ra sao

Chương 1 là từ điển và là bản đồ động lực của cuốn sách. Từ đây, Chương 2 giới thiệu cơ chế của Spring Boot — IoC container, dependency injection, bean, và kiến trúc phân lớp — tức những công cụ cụ thể để hiện thực một thiết kế tốt trong thực tế. Sau đó, từ Chương 3 đến Chương 7, mỗi nguyên lý SOLID giải quyết một khía cạnh cụ thể của "chi phí thay đổi" mà ta vừa phân tích, và tất cả đều dùng lại chính EnrollmentManager cùng hệ thống LMS này. Chương 8 cho thấy testability vừa là kết quả vừa là thước đo của thiết kế tốt, còn Chương 9 nâng mọi thứ lên cấp kiến trúc. Mỗi khi gặp một nguyên lý mới, hãy tự hỏi: nó đang giảm coupling, tăng cohesion, hay cả hai — và nó khắc phục dấu hiệu nào trong bốn dấu hiệu ở đầu chương?


Tóm tắt

  • Thước đo thực tế của thiết kế là chi phí thay đổi về sau, không phải tốc độ viết lần đầu; Design Stamina Hypothesis giải thích vì sao bỏ qua thiết kế chỉ nhanh trong ngắn hạn.

  • Bốn dấu hiệu của thiết kế kém — rigidity, fragility, immobility, viscosity — đều bắt nguồn từ coupling cao và cohesion thấp.

  • Coupling (phụ thuộc giữa các thành phần) và cohesion (sự tập trung bên trong một thành phần) là hai trục cốt lõi, mỗi trục là một thang bậc; mục tiêu là low coupling và high (functional) cohesion.

  • Separation of Concerns tách mỗi mối quan tâm ra một chỗ; connascence là cách nhìn tinh vi hơn về các dạng phụ thuộc.

  • Code smell, God object và technical debt là tên gọi của thiết kế đang xuống cấp; refactoring (có test bảo vệ) là cách trả nợ.

  • Năm nguyên lý SOLID là năm công cụ cho cùng một mục tiêu: giảm coupling, tăng cohesion, hạ chi phí thay đổi. EnrollmentManager là ví dụ xuyên suốt ta sẽ cải thiện dần qua từng chương.


Bài tập cuối chương

Hãy hoàn thành bộ câu hỏi, code-spotting và bài thảo luận cuối chương, đặc biệt là phần phân tích EnrollmentManager, trước khi học tiếp. Kết quả của bài phân tích này sẽ là baseline để bạn so sánh các lần refactor ở những chương sau.

Hỏi đáp

Đăng nhập để đặt câu hỏi và tham gia thảo luận.