Soldemy
0%

SOLID trong Java Spring Boot

Single Responsibility Principle (SRP)

22 phút đọc

Single Responsibility Principle (SRP)

Đến cuối Chương 2, EnrollmentService đã trông gọn hơn nhiều so với God object EnrollmentManager ban đầu: nó nằm trong một kiến trúc phân lớp, nhận dependency qua constructor, và có thể test được. Nhưng nếu nhìn kỹ vào method enroll, ta sẽ thấy nó vẫn đang làm nhiều việc thuộc về những nhóm người khác nhau: tính học phí và áp khuyến mãi là chuyện của đội tài chính, còn soạn nội dung thông báo là chuyện của đội đào tạo. Hai nhóm này thay đổi yêu cầu vì những lý do hoàn toàn khác nhau, vào những thời điểm khác nhau — nhưng mọi thay đổi của cả hai đều buộc phải mở cùng một class. Đây chính là vấn đề mà Single Responsibility Principle giải quyết.

SRP là nguyên lý đầu tiên trong SOLID và cũng là nguyên lý bị hiểu sai nhiều nhất. Phần lớn lập trình viên nghe câu "mỗi class chỉ làm một việc" rồi dừng lại ở đó, và cách hiểu đó vừa mơ hồ vừa dễ dẫn tới tách class quá đà. Chương này trình bày SRP theo đúng ý tác giả của nó, gắn vào cơ chế Spring đã học ở Chương 2, và quan trọng không kém — chỉ ra khi nào thì KHÔNG nên tách, vì áp dụng SRP thái quá cũng có hại như bỏ qua nó.


Khi hai nhóm người cùng chạm một class

Hãy cụ thể hóa tình huống. Trong hệ thống LMS, đội tài chính thông báo: từ quý sau, khóa học nội bộ cho nhân viên được miễn phí, và mã khuyến mãi mùa lễ tăng mức giảm lên 30%. Một lập trình viên mở EnrollmentService ra sửa nhánh tính phí. Vài ngày sau, đội đào tạo yêu cầu đổi lời chào trong email xác nhận cho thân thiện hơn. Lại một lập trình viên khác mở đúng EnrollmentService đó ra sửa đoạn soạn thông báo. Hai thay đổi không liên quan gì đến nhau về nghiệp vụ, nhưng cùng chạm một file — và trong lần sửa thứ hai, một dòng bị đặt nhầm chỗ khiến học phí hiển thị sai trong thông báo. Một class phục vụ hai nhóm người đã trở thành điểm va chạm.

Vấn đề này không phải chuyện thẩm mỹ. Khi hai đội cùng sửa một class, họ tạo ra merge conflict thường xuyên hơn; mỗi thay đổi của đội này có nguy cơ làm hỏng phần của đội kia; và không ai thật sự "sở hữu" class đó để chịu trách nhiệm về tính đúng đắn của nó. Đây là biểu hiện trực tiếp của cohesion thấp mà Chương 1 đã mô tả, nhìn từ góc độ con người thay vì góc độ code.

Ghi chú

Câu hỏi định hướng cả chương: một class nên trả lời cho bao nhiêu nhóm người? SRP trả lời: đúng một. Mỗi khi một class có thể bị yêu cầu thay đổi bởi hai nhóm người khác nhau vì hai lý do khác nhau, nó đang gánh nhiều hơn một trách nhiệm.

SRP thật sự nói gì

Robert C. Martin phát biểu Single Responsibility Principle bằng một câu ngắn: một class chỉ nên có một lý do để thay đổi (one reason to change). Định nghĩa này súc tích nhưng dễ gây tranh cãi quanh từ "lý do", nên nhiều năm sau, trong một bài viết năm 2014, ông làm rõ lại: hãy gom những thứ thay đổi vì cùng một lý do vào một chỗ, và tách những thứ thay đổi vì những lý do khác nhau.

Điểm mấu chốt trong bản làm rõ đó là khái niệm actor. Martin định nghĩa lại SRP thành: một module chỉ nên chịu trách nhiệm trước một, và chỉ một, actor. Actor là một nhóm người — một tập stakeholder hoặc user — cùng yêu cầu một loại thay đổi. Trong ví dụ trên, đội tài chính và đội đào tạo là hai actor khác nhau; vì EnrollmentService phải đáp ứng cả hai, nó vi phạm SRP. Nói cách khác, "một lý do để thay đổi" thực chất nghĩa là "một actor yêu cầu thay đổi". SRP, theo lời chính Martin, là một nguyên lý về con người trước khi là một nguyên lý về code.

Từ đó suy ra định nghĩa của responsibility trong SRP: responsibility là nghĩa vụ của một module trước một actor cụ thể. Một class có nhiều responsibility khi nó phục vụ nhiều actor. Cách hiểu này chính xác hơn hẳn cách nói "một class một việc", vì nó cho ta một tiêu chí kiểm tra rõ ràng: đừng hỏi "class này làm mấy việc?", hãy hỏi "class này phải trả lời cho mấy nhóm người?".

Đây cũng là nơi cần gỡ hiểu lầm phổ biến nhất về SRP. "Một lý do để thay đổi" không có nghĩa là "một class chỉ được có một method", cũng không có nghĩa là "một method chỉ được làm một phép tính". Một class hoàn toàn có thể có nhiều method và xử lý nhiều bước, miễn là tất cả cùng phục vụ một actor và cùng thay đổi vì một lý do. Hiểu sai điều này dẫn thẳng tới việc tách class quá đà — một cái bẫy sẽ được bàn kỹ ở cuối chương.


Vì sao SRP đáng giá trong code doanh nghiệp

Lợi ích của SRP ánh xạ trực tiếp vào những chi phí đã nêu ở Chương 1. Khi mỗi class chỉ phục vụ một actor, cohesion của nó cao một cách tự nhiên: mọi method bên trong cùng phục vụ một mục đích. Coupling giữa các mối quan tâm giảm, vì logic tài chính không còn nằm cạnh logic thông báo để vô tình phụ thuộc lẫn nhau. Và vì một thay đổi nghiệp vụ chỉ chạm đúng class phụ trách actor tương ứng, blast radius — phạm vi ảnh hưởng của một thay đổi — được thu hẹp.

Có một lợi ích thực tế nữa mà bản định nghĩa theo actor làm nổi bật: giảm va chạm giữa các đội. Khi logic của đội tài chính và đội đào tạo nằm ở hai class riêng, hai đội có thể sửa song song mà không xung đột với nhau, ít merge conflict hơn, và mỗi class có một chủ sở hữu rõ ràng. Cuối cùng, class nhỏ và tập trung dễ test hơn: bạn test quy tắc tính phí mà không phải dựng cả luồng gửi thông báo. Testability, một lần nữa, vừa là hệ quả vừa là thước đo của thiết kế tốt.


Làm sao tìm ra ranh giới trách nhiệm

Biết định nghĩa SRP là một chuyện; biết đặt đường cắt ở đâu lại là chuyện khó hơn. Câu hỏi thực dụng nhất là: "Ai sẽ yêu cầu thay đổi đoạn code này, và vì lý do gì?". Nếu hai đoạn code trong cùng một class trả lời cho hai nhóm người khác nhau — đội tài chính đổi công thức phí, đội đào tạo đổi lời thông báo, đội hạ tầng đổi cách lưu trữ — thì đó là dấu hiệu chúng nên nằm ở những class khác nhau.

Một cách nhìn sâu hơn đến từ Responsibility-Driven Design: thay vì hỏi "class này chứa dữ liệu gì", hãy hỏi "class này đóng vai trò gì và chịu trách nhiệm gì trong hệ thống". Cách tiếp cận này gán cho mỗi object một stereotype rõ ràng — chẳng hạn Information Holder (giữ và cung cấp dữ liệu), Service Provider (thực hiện một loại công việc), Coordinator (điều phối các object khác) — và một object có vai trò rõ ràng thì tự nhiên có một trách nhiệm rõ ràng. Trong ví dụ của chúng ta, EnrollmentService nên là một Coordinator điều phối quy trình ghi danh, còn việc tính phí thuộc về một Service Provider chuyên trách.


Tách EnrollmentService theo actor

Dưới đây là EnrollmentService ở trạng thái vi phạm SRP. Hãy đọc và tự đánh dấu: đoạn nào thuộc về actor nào.

JavaScript
@Service
public class EnrollmentService {

    private final EnrollmentRepository enrollmentRepository;
    private final NotificationService notificationService;

    public EnrollmentService(EnrollmentRepository enrollmentRepository,
                             NotificationService notificationService) {
        this.enrollmentRepository = enrollmentRepository;
        this.notificationService = notificationService;
    }

    public void enroll(Long studentId, Long courseId, String promoCode) {
        if (enrollmentRepository.existsBy(studentId, courseId)) {
            throw new IllegalStateException("Đã ghi danh khóa này rồi");
        }

        // Lý do thay đổi #1 — actor: đội tài chính (quy tắc học phí & khuyến mãi)
        double fee = enrollmentRepository.loadBaseFee(courseId);
        if ("WELCOME10".equals(promoCode)) {
            fee = fee * 0.9;
        } else if ("VIP50".equals(promoCode)) {
            fee = fee * 0.5;
        }
        if (enrollmentRepository.isInternalCourse(courseId)) {
            fee = 0; // khóa nội bộ miễn phí
        }

        // Lý do thay đổi #2 — actor: đội đào tạo (nội dung & giọng điệu thông báo)
        String message = "Chào mừng bạn đến với khóa học! "
                + "Học phí của bạn là " + fee + " VND. Chúc bạn học tốt.";

        enrollmentRepository.save(new Enrollment(studentId, courseId, fee));
        notificationService.send(studentId, message);
    }
}

Method enroll này có ít nhất hai lý do để thay đổi, thuộc hai actor. Áp dụng kỹ thuật Extract Class, ta tách mỗi trách nhiệm ra một class chuyên trách, để EnrollmentService chỉ còn giữ đúng một vai trò: điều phối quy trình ghi danh.

JavaScript
// Actor: đội tài chính. Mọi thay đổi quy tắc phí/khuyến mãi chỉ chạm class này.
@Component
public class FeeCalculator {

    private final CourseRepository courseRepository;

    public FeeCalculator(CourseRepository courseRepository) {
        this.courseRepository = courseRepository;
    }

    public double calculate(Long courseId, String promoCode) {
        if (courseRepository.isInternalCourse(courseId)) {
            return 0;
        }
        double fee = courseRepository.loadBaseFee(courseId);
        return applyPromo(fee, promoCode);
    }

    private double applyPromo(double fee, String promoCode) {
        if ("WELCOME10".equals(promoCode)) return fee * 0.9;
        if ("VIP50".equals(promoCode)) return fee * 0.5;
        return fee;
    }
}

// Actor: đội đào tạo. Mọi thay đổi nội dung/giọng điệu thông báo chỉ chạm class này.
@Component
public class EnrollmentMessageComposer {
    public String welcomeMessage(double fee) {
        return "Chào mừng bạn đến với khóa học! Học phí của bạn là " + fee + " VND. Chúc bạn học tốt.";
    }
}

// EnrollmentService giờ chỉ có một lý do để thay đổi: quy trình ghi danh thay đổi.
@Service
public class EnrollmentService {

    private final EnrollmentRepository enrollmentRepository;
    private final FeeCalculator feeCalculator;
    private final EnrollmentMessageComposer messageComposer;
    private final NotificationService notificationService;

    public EnrollmentService(EnrollmentRepository enrollmentRepository,
                             FeeCalculator feeCalculator,
                             EnrollmentMessageComposer messageComposer,
                             NotificationService notificationService) {
        this.enrollmentRepository = enrollmentRepository;
        this.feeCalculator = feeCalculator;
        this.messageComposer = messageComposer;
        this.notificationService = notificationService;
    }

    public void enroll(Long studentId, Long courseId, String promoCode) {
        if (enrollmentRepository.existsBy(studentId, courseId)) {
            throw new IllegalStateException("Đã ghi danh khóa này rồi");
        }
        double fee = feeCalculator.calculate(courseId, promoCode);
        enrollmentRepository.save(new Enrollment(studentId, courseId, fee));
        notificationService.send(studentId, messageComposer.welcomeMessage(fee));
    }
}

Sau khi tách, mỗi class có một chủ sở hữu rõ ràng. Đội tài chính đổi quy tắc phí chỉ chạm FeeCalculator; đội đào tạo đổi lời thông báo chỉ chạm EnrollmentMessageComposer; hai đội không còn xung đột với nhau. EnrollmentService trở thành một Coordinator mỏng: đọc method enroll là hiểu ngay quy trình ghi danh gồm những bước nào, không bị chi tiết phí hay câu chữ thông báo che khuất. Và mỗi class giờ test được độc lập — có thể kiểm tra FeeCalculator với đủ tổ hợp khuyến mãi mà không cần đến database hay luồng thông báo.

Minh họa cụ thể: test cho FeeCalculator chỉ cần một mock CourseRepository, không phải dựng cả luồng ghi danh. Đây là lợi ích testability mà việc tách theo actor mang lại.

JavaScript
class FeeCalculatorTest {

    @Test
    void ap_dung_ma_VIP50_giam_mot_nua() {
        CourseRepository courseRepo = mock(CourseRepository.class);
        when(courseRepo.isInternalCourse(10L)).thenReturn(false);
        when(courseRepo.loadBaseFee(10L)).thenReturn(1_000_000.0);

        FeeCalculator calculator = new FeeCalculator(courseRepo);

        assertEquals(500_000.0, calculator.calculate(10L, "VIP50"));
    }

    @Test
    void khoa_noi_bo_duoc_mien_phi() {
        CourseRepository courseRepo = mock(CourseRepository.class);
        when(courseRepo.isInternalCourse(99L)).thenReturn(true);

        FeeCalculator calculator = new FeeCalculator(courseRepo);

        assertEquals(0.0, calculator.calculate(99L, null));
    }
}

Ghi chú

Bản refactor này đã tuân thủ SRP, nhưng chưa "xong SOLID". Chú ý `FeeCalculator` vẫn dùng chuỗi if-else để xử lý từng mã khuyến mãi: thêm một loại khuyến mãi mới vẫn buộc phải mở class này ra sửa. Đó là vi phạm Open/Closed Principle — chủ đề Chương 4 sẽ giải quyết trên chính `FeeCalculator` này.

SRP không chỉ ở cấp class

SRP thường được nói tới ở cấp class, nhưng cùng một ý tưởng áp dụng ở nhiều cấp độ. Ở cấp method, một method nên làm một việc ở một mức trừu tượng nhất quán: method enroll sau refactor đọc như một danh sách các bước cùng cấp (kiểm tra trùng, tính phí, lưu, thông báo), không trộn lẫn chi tiết cấp thấp vào giữa. So sánh hai cách viết cùng một method:

JavaScript
// Trộn nhiều mức trừu tượng trong một method — khó đọc, khó thay đổi
public void issueCertificates(Long courseId) {
    for (Enrollment e : repository.findCompleted(courseId)) {
        // ... 20 dòng dựng layout PDF chi tiết ngay tại đây ...
        // ... 10 dòng ghép nội dung email ngay tại đây ...
        // ... gọi API lưu trữ ngay tại đây ...
    }
}

// Mỗi bước ở cùng một mức trừu tượng, là một lời gọi có tên rõ nghĩa — dễ đọc
public void issueCertificates(Long courseId) {
    for (Enrollment e : repository.findCompleted(courseId)) {
        byte[] pdf = certificateRenderer.render(e);
        certificateStorage.save(pdf, e.studentId());
        notificationService.notifyCertificateReady(e.studentId());
    }
}

Ở cấp module hoặc package, một package nên gom các class cùng phục vụ một vùng nghiệp vụ, đúng theo tinh thần package by feature đã bàn ở Chương 2. Ở cấp cao nhất, một bounded context hay một microservice cũng nên có một trách nhiệm nghiệp vụ rõ ràng — điều Chương 9 sẽ quay lại.

Nhìn theo cách này, SRP và Separation of Concerns (Chương 1) là cùng một nguyên lý ở các quy mô khác nhau: tách những mối quan tâm khác nhau ra, và giữ những gì cùng một mối quan tâm lại với nhau. SRP là cách phát biểu nguyên lý đó ở cấp class, với tiêu chí sắc bén là "một actor".


Tách tới đâu là đủ: mặt trái của SRP

Áp dụng SRP thái quá cũng làm tăng cost of change. Nếu hiểu nhầm SRP thành "một class một việc" rồi tách mọi operation thành một type riêng, codebase sẽ có nhiều class không đại diện change boundary thực tế; để hiểu một flow, người đọc phải điều hướng qua nhiều file mà không nhận thêm isolation có giá trị. Đây là over-engineering và là một dạng Speculative Generality.

Tiêu chí actor cho ta ranh giới đúng. Xét một ví dụ ngược: giả sử NotificationService có hai method sendWelcomeEmailsendCompletionEmail. Có nên tách thành hai class WelcomeEmailSenderCompletionEmailSender không? Nếu cả hai cùng phục vụ một actor (đội đào tạo, cùng thay đổi khi chính sách email thay đổi) và cùng dùng chung một cơ chế gửi, thì tách chúng ra chỉ thêm boilerplate mà không giảm được lý do thay đổi nào. Chúng thay đổi vì cùng một lý do, nên theo bản làm rõ 2014 của Martin, chúng thuộc về nhau.

Một quy tắc thực dụng để tránh cả hai thái cực: đừng tách chỉ vì "class dài" hay "nhiều method"; hãy tách khi bạn nhận ra hai lý do thay đổi thuộc hai actor cùng sống trong một class. Và đừng gộp chỉ vì "ngại tạo file mới"; hãy gộp khi các đoạn code thật sự thay đổi cùng nhau. Có một châm ngôn đáng nhớ: mỗi pattern sinh ra từ một vấn đề — nếu bạn chưa gặp vấn đề đó, bạn chưa cần pattern đó. Hãy để những lần thay đổi thực tế cho bạn biết đường cắt nằm ở đâu, thay vì cắt sẵn theo linh cảm.


Khi nào áp dụng SRP — và khi nào chưa cần

Một điều cần nói thẳng: SOLID là các hướng dẫn (guideline), không phải luật bắt buộc áp dụng mọi lúc mọi nơi. Sai lầm phổ biến của người mới học SOLID là áp dụng nó một cách giáo điều lên mọi class ngay từ dòng code đầu tiên, biến một tính năng đơn giản thành một mê cung interface và class nhỏ. Đây là over-engineering, và nó cũng làm tăng chi phí thay đổi, chỉ theo một kiểu khác. Có hẳn những phân tích chuyên về các bối cảnh mà áp dụng SOLID chặt chẽ trở nên phản tác dụng.

Vài bối cảnh nên cân nhắc kỹ trước khi tách theo SRP. Thứ nhất là code dùng một lần hoặc prototype chắc chắn sẽ bị bỏ đi — đầu tư tách trách nhiệm không kịp hoàn vốn, đúng như đường cong Design Stamina ở Chương 1 chỉ ra. Thứ hai là tính năng CRUD đơn giản, ổn định, hiếm khi thay đổi — một class gọn nhiều khi dễ đọc hơn năm class tí hon. Thứ ba là những đường code cực nhạy về hiệu năng như hệ real-time hay embedded, nơi thêm tầng gián tiếp và đa hình có thể gây chi phí không mong muốn. Trong các trường hợp này, sự đơn giản có giá trị hơn một khả năng mở rộng mà có thể bạn không bao giờ cần đến.

Nguyên tắc về thời điểm quan trọng nhất là đừng tách quá sớm. YAGNI — You Aren't Gonna Need It, ra đời từ Extreme Programming của Kent Beck — khuyên xây giải pháp đơn giản nhất cho vấn đề hôm nay, thay vì thiết kế sẵn cho một tương lai tưởng tượng. Đi kèm là Rule of Three của Fowler: hai đoạn tương tự thì chưa cần tách, đến lần thứ ba lặp lại mới trích ra một abstraction. Lý do sâu xa là tách sớm dễ chọn nhầm abstraction, mà một abstraction sai còn tốn kém hơn cả trùng lặp — như Dan Abramov và Kent C. Dodds nhấn mạnh, trùng lặp một chút thường rẻ hơn nhiều so với một abstraction sai buộc mọi nơi phải sống chung.

Áp vào SRP cụ thể: chưa nên tách một class chỉ vì dự đoán nó sẽ phục vụ nhiều actor. Khi actor thứ hai thật sự xuất hiện qua requirement hoặc change history độc lập, đường cắt có evidence rõ hơn. Mức đầu tư cũng nên tập trung vào vùng thay đổi thường xuyên, vì đó là nơi cost of change tích lũy nhanh nhất; vùng ổn định có thể giữ cấu trúc đơn giản hơn.

Hai tình huống trong cùng hệ thống LMS cho thấy ranh giới này. EnrollmentService vừa tính học phí theo chính sách của bộ phận tài chính, vừa soạn nội dung email theo yêu cầu của đội đào tạo: hai actor có lịch thay đổi và bộ test khác nhau, nên tách FeeCalculatorEnrollmentMessageComposer làm giảm blast radius thật sự. Ngược lại, một CourseCategoryService nội bộ chỉ cung cấp các thao tác tạo, đổi tên và ngừng sử dụng category theo cùng quy tắc của đội quản trị catalog. Tách ba thao tác CRUD ấy thành ba class một-method không cô lập thêm actor nào; nó chỉ phân tán một transaction đơn giản qua nhiều file. Trường hợp đầu nên áp dụng SRP mạnh, trường hợp sau nên giữ thiết kế gọn cho tới khi xuất hiện lý do thay đổi độc lập.

Không phải mọi sự kết hợp trách nhiệm ở tầng implementation đều có thể loại bỏ. Robert C. Martin từng phân tích một modem implementation phải gắn việc truyền dữ liệu và quản lý kết nối vì ràng buộc hardware hoặc operating system; giải pháp thực dụng là tách các interface để phần còn lại của hệ thống không phụ thuộc vào sự kết hợp đó, rồi cô lập concrete class ở composition root. Tương tự, một legacy JPA adapter trong LMS đôi khi phải vừa gọi stored procedure vừa chuyển đổi result set vì giới hạn database. Team vẫn có thể giữ port nghiệp vụ sạch và đặt thỏa hiệp kỹ thuật phía sau adapter, thay vì tạo thêm nhiều lớp chỉ để cấu trúc bên trong trông thuần khiết.

Khi review, hãy cân bốn dữ kiện trước khi tách: có bao nhiêu actor thật sự yêu cầu thay đổi; các phần code có thường thay đổi độc lập không; việc thay đổi một phần có buộc chạy lại hoặc sửa test của phần kia không; và sau khi tách, một lập trình viên phải đi qua bao nhiêu file để hiểu trọn use case. Ba dữ kiện đầu nghiêng về tách, còn dữ kiện cuối bảo vệ codebase khỏi phân mảnh quá mức. Nếu chưa đủ bằng chứng, ghi nhận code smell và theo dõi lịch sử thay đổi; quyết định “chưa tách” hôm nay không cấm team refactor khi actor thứ hai xuất hiện.


SRP ngoài thực tế: những trường hợp thường gặp

God Service là biểu hiện thường gặp của vi phạm SRP trong ứng dụng Spring. Một @Service bắt đầu nhỏ rồi nhận thêm operation phục vụ finance, content, notification và approval; các nhóm behavior thay đổi theo actor khác nhau nhưng cùng đi qua một file. Cách xử lý không phải tách theo số method mà là xác định actor và Extract Class theo change boundary đã quan sát được, giống EnrollmentService trong chương.

Một trường hợp kinh điển khác, do chính Robert C. Martin dùng để giải thích khái niệm actor, là một class gộp ba việc thay đổi vì ba nhóm người khác nhau: tính lương (do bộ phận tài chính quy định), báo cáo giờ làm (do bộ phận vận hành quy định), và lưu vào database (do bộ phận kỹ thuật quy định). Ba actor, ba lý do để thay đổi, nên đó là ba trách nhiệm cần tách. Trong domain LMS của chúng ta, khuôn mẫu ấy lặp lại y hệt: quy tắc học phí, nội dung thông báo, và cách lưu trữ là ba mối quan tâm của ba actor khác nhau.

Trường hợp thứ ba ít được nói tới nhưng rất thật ở môi trường nhiều đội: xung đột quyền sở hữu. Khi hai đội buộc phải sửa chung một class lớn, tốc độ của cả hai giảm vì phải chờ nhau, review chéo phần mình không nắm, và giải quyết merge conflict liên tục. Tách theo actor không chỉ là quyết định kỹ thuật mà còn là quyết định tổ chức: nó cho mỗi đội một không gian để làm việc độc lập.


Dấu hiệu nhận biết vi phạm SRP khi review code

Khi review một pull request, vài dấu hiệu thực dụng giúp nhận ra một class đang gánh nhiều hơn một trách nhiệm. Chúng không phải bằng chứng tuyệt đối mà là tín hiệu để dừng lại và hỏi câu hỏi về actor.

  • Tên class chung chung hoặc chứa chữ "And", cùng các hậu tố ôm đồm như Manager, Processor, Helper, Util — thường là nơi nhiều trách nhiệm tụ lại.

  • Constructor nhận nhiều dependency không liên quan nhau: nếu một class cần cả MailSender, PdfRenderer lẫn TaxRuleRepository, nhiều khả năng nó đang phục vụ nhiều actor.

  • Các method trong class ở những mức trừu tượng rất khác nhau, hoặc thao tác trên những nhóm dữ liệu tách biệt không dùng chung.

  • Class import từ nhiều package không liên quan (web, mail, io, jdbc) cùng lúc.

  • Mô tả class phải dùng chữ "và": "class này tính phí và gửi thông báo và lưu dữ liệu".

Khi thấy các dấu hiệu này, đừng vội tách. Hãy hỏi câu hỏi quyết định: những phần này có thay đổi vì những lý do khác nhau, do những nhóm người khác nhau yêu cầu không? Nếu có, đó là lúc dùng Extract Class; nếu không, giữ nguyên có thể vẫn đúng.


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

  • "SRP nghĩa là mỗi class chỉ có một method." Không. SRP nói về một lý do để thay đổi, tức một actor; một class có thể có nhiều method miễn tất cả cùng phục vụ một actor.

  • "Cứ tách càng nhỏ càng tốt." Không. Tách quá đà tạo ra vô số class tí hon và làm luồng nghiệp vụ khó theo dõi; đó là over-engineering.

  • "SRP là chuyện thẩm mỹ code." Không. Nó là chuyện chi phí thay đổi và cả chuyện tổ chức đội — ai sửa gì, ai sở hữu gì.

  • "Class dài là vi phạm SRP." Không nhất thiết. Độ dài là một code smell gợi ý nên xem lại, nhưng tiêu chí quyết định là số actor, không phải số dòng.

  • "Đã tách theo SRP là đã SOLID." Không. Tách trách nhiệm mới là bước đầu; các nguyên lý còn lại (OCP, LSP, ISP, DIP) xử lý những khía cạnh khác của cùng đoạn code.


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

SRP là bước đầu tiên và là nền cho các nguyên lý sau. Nó chính là Separation of Concerns (Chương 1) phát biểu ở cấp class, và nó dựa vào Dependency Injection (Chương 2) để nối các class đã tách lại với nhau qua constructor. Quan trọng hơn, việc tách trách nhiệm mở đường cho Open/Closed Principle: chỉ khi quy tắc tính phí đã nằm gọn trong FeeCalculator, ta mới có thể làm cho nó "mở để mở rộng, đóng với sửa đổi" ở Chương 4. Một hệ thống chưa tách trách nhiệm thì không thể mở rộng sạch sẽ được. Mỗi khi tách một trách nhiệm, hãy tự hỏi: class mới này trả lời cho actor nào, và nó có đúng một lý do để thay đổi chưa?


Tóm tắt

  • SRP: một class chỉ nên có một lý do để thay đổi. Bản làm rõ 2014 của Martin: một module chỉ nên chịu trách nhiệm trước một actor.

  • Responsibility là nghĩa vụ trước một actor; tiêu chí kiểm tra là "class này trả lời cho mấy nhóm người?", không phải "class này làm mấy việc?".

  • SRP cho cohesion cao, coupling thấp, testability tốt hơn, và giảm va chạm giữa các đội.

  • Tìm ranh giới bằng cách hỏi "ai yêu cầu thay đổi và vì sao"; Responsibility-Driven Design gợi ý gán vai trò rõ ràng cho mỗi class.

  • Extract Class theo actor là kỹ thuật chính; EnrollmentService trở thành Coordinator, còn FeeCalculatorEnrollmentMessageComposer mỗi class một actor.

  • Đừng tách quá đà: tiêu chí là actor và lý do thay đổi, không phải số dòng hay số method. Mỗi pattern sinh ra từ một vấn đề.

  • Áp dụng mạnh khi nhiều actor thay đổi độc lập; giữ một class gọn khi cùng actor, cùng transaction và luồng còn dễ hiểu. Xem xét lại khi lịch sử thay đổi cho thấy ranh giới mới.

  • Tách trách nhiệm là bước mở đường cho Open/Closed ở Chương 4.


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

Trong phần bài tập cuối chương, hãy ghi rõ reason to change và evidence trước khi đề xuất tách class. Số dòng không phải tiêu chí quyết định; điều cần chứng minh là các trách nhiệm thay đổi vì những actor khác nhau.

Hỏi đáp

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