Soldemy
0%

SOLID trong Java Spring Boot

Interface Segregation Principle (ISP)

33 phút đọc

Interface Segregation Principle (ISP)

Cuối Chương 5, SelfPacedCourse đã không còn bị ép nhận capability lên lịch học trực tiếp mà nó không thể thực hiện. Cách sửa đó giải quyết một vi phạm LSP, nhưng đồng thời để lộ câu hỏi thiết kế kế tiếp: nếu một object có nhiều capability hợp lệ, mọi client có cần nhìn thấy toàn bộ số capability ấy không? Trong một hệ thống nhỏ, câu hỏi này có vẻ không quan trọng. Trong codebase có nhiều module và nhiều team, câu trả lời quyết định phạm vi ảnh hưởng của mỗi thay đổi.

Hãy tiếp tục với hệ thống enrollment. Ban đầu, team chỉ cần lưu một enrollment và kiểm tra sinh viên đã đăng ký khóa học hay chưa. Vài tháng sau, dashboard cần truy vấn lịch sử học; Finance cần tổng hợp doanh thu; bộ phận vận hành cần xóa dữ liệu hết thời hạn lưu giữ. Cách nhanh nhất là tiếp tục thêm method vào EnrollmentOperations, interface mà mọi service đang dùng. Yêu cầu báo cáo của Finance khi đó buộc adapter in-memory phục vụ test đăng ký phải thêm một method không có ý nghĩa; luồng dashboard nhìn thấy cả thao tác xóa; một thay đổi vốn chỉ liên quan báo cáo làm hàng loạt module phải build và test lại.

Không client nào gọi toàn bộ interface, nhưng tất cả đều phụ thuộc vào toàn bộ contract. Đây là vấn đề mà Interface Segregation Principle xử lý. ISP không phủ nhận việc một object có thể làm nhiều việc. Nó yêu cầu ta trao cho mỗi client đúng phần giao diện mà client cần, để thay đổi ở một capability không lan sang những bên không liên quan.

Ghi chú

Câu hỏi định hướng chương này: client có dùng hết contract mà nó phụ thuộc không; nếu không, các method dư có thật sự thuộc cùng một vai trò và thay đổi cùng nhau không?

ISP thật sự nói gì

Interface Segregation Principle có thể được phát biểu ngắn gọn như sau: client không nên bị buộc phụ thuộc vào những method mà nó không sử dụng. Robert C. Martin trình bày nguyên tắc này từ một vấn đề cụ thể của thiết kế hướng đối tượng: một interface phục vụ nhiều nhóm client khác nhau sẽ truyền thay đổi của nhóm này sang nhóm khác. Vì vậy, mục tiêu của ISP không đơn thuần là làm interface ngắn hơn; mục tiêu là kiểm soát hướng và phạm vi của dependency.

Ba từ trong phát biểu trên cần được hiểu chính xác. Client là phần code sử dụng một abstraction: một @Service, một scheduled job, một module, một team tích hợp SDK, thậm chí một hệ thống gọi API. Sử dụng không chỉ có nghĩa gọi method ở runtime. Client còn phải import type, hiểu contract, biên dịch cùng type và có thể phải test hoặc deploy lại khi type đổi. Phụ thuộc vì thế bao gồm source dependency, binary dependency và dependency về tri thức: lập trình viên phải phân biệt phần nào an toàn, phần nào không áp dụng dù tất cả cùng xuất hiện trên một interface.

Trong Java, từ interface thường khiến người học chỉ nghĩ tới keyword cùng tên. ISP rộng hơn cú pháp đó. Một abstract class có hai mươi public method, một facade công bố quá nhiều operation, hoặc một module API kéo theo các package mà consumer không dùng đều có thể tạo ra cùng vấn đề. Ngược lại, việc một class implement nhiều Java interface không phải dấu hiệu thiết kế xấu. Class có thể cung cấp nhiều vai trò hợp lệ; mỗi client chỉ cần được gắn với type đại diện cho vai trò của nó.

Clean Architecture mở rộng hậu quả này lên cấp kiến trúc. Trong ngôn ngữ static typing như Java, một consumer phụ thuộc vào module rộng có thể bị kéo vào chu kỳ rebuild hoặc redeploy chỉ vì một phần khác của module thay đổi. Nếu module rộng còn mang theo framework hoặc database dependency nặng, phần “hành lý” không dùng ấy có thể lan qua ranh giới kiến trúc. ISP vì thế không phải quy tắc trang trí class diagram; nó là cách giảm bán kính thay đổi và giảm số thành phần phải biết về nhau.


Fat Interface không được xác định bằng số lượng method

Fat Interface là interface trộn nhiều nhóm hành vi phục vụ những client khác nhau, khiến client hoặc implementation phải gánh các method không liên quan. Từ “fat” dễ dẫn tới một luật máy móc: cứ thấy nhiều method thì tách. Luật đó không chính xác. Một interface mười method vẫn có thể cohesive nếu các method cùng mô tả một vai trò và mọi client của vai trò ấy thường sử dụng chúng cùng nhau. Ngược lại, interface chỉ có ba method vẫn có thể thiếu cohesion nếu ba client khác nhau mỗi bên chỉ dùng một method, các method thay đổi vì ba actor độc lập.

Interface Cohesion là mức độ các method trong interface cùng phục vụ một vai trò mạch lạc. Ta không đo cohesion bằng cách hỏi “các method có thao tác trên cùng entity không?”. saveEnrollment(), calculateRevenue()deleteExpired() đều liên quan entity Enrollment, nhưng chúng phục vụ ba mục tiêu, ba quyền truy cập và ba nhịp thay đổi khác nhau. Việc cùng chạm một bảng database không biến chúng thành một contract cohesive.

Role Interface là interface mô tả một vai trò nhìn từ phía client. Tên role tốt nói lên capability mà client cần, chẳng hạn StudentEnrollmentQuery hoặc ExpiredEnrollmentPurger. Nó không nhất thiết có đúng một method. Luồng đăng ký thường phải kiểm tra enrollment đang hoạt động rồi lưu enrollment mới trong cùng use case; existsActiveByStudentAndCourse()save() có thể cùng nằm trong EnrollmentRegistrationStore vì chúng được một client dùng cùng nhau và thay đổi theo cùng chính sách đăng ký.

Điểm này cũng phân biệt ISP với SRP. SRP hỏi một module chịu trách nhiệm trước actor nào và có bao nhiêu lý do để thay đổi. ISP hỏi một client đang bị ép phụ thuộc vào phần contract nào mà nó không cần. Hai nguyên tắc thường cùng phát hiện một interface bị phình: các method phục vụ nhiều actor cũng thường được nhiều client khác nhau sử dụng. Tuy nhiên, góc phân tích không giống nhau. Khi review ISP, dữ liệu quan trọng nhất là bản đồ client–method và lịch sử thay đổi, không phải chỉ tên class hay số dòng code.

Trong giáo trình OOP, interface thường được trình bày như danh sách method mà class phải implement. Trong code doanh nghiệp, cần thêm một góc nhìn: interface là ngân sách dependency mà ta cấp cho client. Mỗi method công khai thêm một khả năng client có thể gọi, một hành vi implementation phải duy trì và một điểm thay đổi có thể lan ra. Contract rộng đôi khi hoàn toàn đáng giá; nhưng độ rộng đó phải đến từ nhu cầu thật, không phải từ thói quen gom mọi operation liên quan cùng entity.


Case trước refactor: `EnrollmentOperations` lớn dần theo từng yêu cầu

Phiên bản đầu tiên của EnrollmentOperations không được tạo ra với chủ ý làm một “siêu interface”. Nó lớn dần theo cách rất thường gặp: mỗi feature mới tìm đến abstraction đã có vì đây là đường ngắn nhất để truy cập dữ liệu enrollment. Luồng đăng ký cần hai method đầu; dashboard cần method thứ ba; Finance thêm method thứ tư; retention job thêm method cuối.

JavaScript
public interface EnrollmentOperations {

    boolean existsActiveByStudentAndCourse(UUID studentId, UUID courseId);

    Enrollment save(Enrollment enrollment);

    List<Enrollment> findByStudentId(UUID studentId);

    RevenueSummary summarizeRevenue(LocalDate from, LocalDate to);

    long deleteExpiredBefore(Instant threshold);
}

Mỗi client nhận cùng một dependency, dù phạm vi sử dụng khác nhau hoàn toàn. Constructor injection làm dependency hiện rõ, nhưng type của dependency vẫn quá rộng.

JavaScript
@Service
public class EnrollmentService {
    private final EnrollmentOperations operations;

    public EnrollmentService(EnrollmentOperations operations) {
        this.operations = operations;
    }

    @Transactional
    public Enrollment enroll(UUID studentId, UUID courseId) {
        if (operations.existsActiveByStudentAndCourse(studentId, courseId)) {
            throw new DuplicateEnrollmentException(studentId, courseId);
        }
        return operations.save(Enrollment.start(studentId, courseId));
    }
}

@Service
public class StudentDashboardService {
    private final EnrollmentOperations operations;

    public StudentDashboardService(EnrollmentOperations operations) {
        this.operations = operations;
    }

    public List<EnrollmentView> getEnrollments(UUID studentId) {
        return operations.findByStudentId(studentId).stream()
            .map(EnrollmentView::from)
            .toList();
    }
}

@Component
public class RevenueReportJob {
    private final EnrollmentOperations operations;

    public RevenueReportJob(EnrollmentOperations operations) {
        this.operations = operations;
    }

    public RevenueSummary summarize(LocalDate from, LocalDate to) {
        return operations.summarizeRevenue(from, to);
    }
}

@Component
public class DataRetentionJob {
    private final EnrollmentOperations operations;

    public DataRetentionJob(EnrollmentOperations operations) {
        this.operations = operations;
    }

    @Transactional
    public long purge(Instant threshold) {
        return operations.deleteExpiredBefore(threshold);
    }
}

Code trên vẫn chạy. ISP không nói rằng một interface rộng lập tức tạo bug runtime. Vấn đề xuất hiện khi hệ thống cần thêm implementation hoặc khi một nhóm thay đổi contract. Test của EnrollmentService thường dùng in-memory implementation để kiểm soát trạng thái. Implementation ấy chỉ cần hai operation cho luồng đăng ký, nhưng compiler buộc nó nhận cả báo cáo doanh thu và retention.

JavaScript
public class InMemoryEnrollmentOperations implements EnrollmentOperations {
    private final List<Enrollment> enrollments = new ArrayList<>();

    @Override
    public boolean existsActiveByStudentAndCourse(UUID studentId, UUID courseId) {
        return enrollments.stream().anyMatch(enrollment ->
            enrollment.isActiveFor(studentId, courseId));
    }

    @Override
    public Enrollment save(Enrollment enrollment) {
        enrollments.add(enrollment);
        return enrollment;
    }

    @Override
    public List<Enrollment> findByStudentId(UUID studentId) {
        return enrollments.stream()
            .filter(enrollment -> enrollment.belongsTo(studentId))
            .toList();
    }

    @Override
    public RevenueSummary summarizeRevenue(LocalDate from, LocalDate to) {
        throw new UnsupportedOperationException("Không hỗ trợ báo cáo doanh thu");
    }

    @Override
    public long deleteExpiredBefore(Instant threshold) {
        throw new UnsupportedOperationException("Không hỗ trợ retention");
    }
}

Hai exception cuối không phải lỗi của cấu trúc dữ liệu in-memory. Chúng là bằng chứng interface đã hứa nhiều hơn vai trò mà implementation này cần thực hiện. Nếu InMemoryEnrollmentOperations được truyền nhầm vào RevenueReportJob, chương trình compile nhưng hỏng ở runtime. Đây chính là cầu nối với LSP: ISP giúp thiết kế contract đủ hẹp để implementation không phải giả vờ hỗ trợ hành vi vô nghĩa.

Vấn đề thứ hai là coupling thay đổi. Giả sử Finance bổ sung tham số Currency vào summarizeRevenue(). EnrollmentService không gọi method đó, nhưng source code của nó vẫn import EnrollmentOperations; mọi implementation của interface đều phải được đánh giá và có thể phải sửa. Trong monorepo nhỏ, chi phí có thể chỉ là vài phút build. Với shared library hoặc nhiều team release độc lập, thay đổi ấy có thể kéo theo version bump, regression test và redeploy ở những consumer không nhận được giá trị nào.

Vấn đề thứ ba là phạm vi capability. StudentDashboardService chỉ đọc dữ liệu nhưng type của nó công bố cả deleteExpiredBefore(). Việc service hiện tại không gọi method xóa không phải một boundary. Một thay đổi sau này, một đoạn code bị đặt nhầm hoặc một developer mới đều có thể dùng capability nguy hiểm vì compiler xem đó là lời gọi hợp lệ. ISP không thay thế authorization hay database permission, nhưng một contract hẹp loại bỏ những operation không cần ngay từ source code.


Tìm đường cắt từ phía client

Cách refactor đáng tin cậy bắt đầu bằng usage thực tế. Đừng mở interface rồi chia đều năm method thành năm file. Hãy liệt kê client, đánh dấu method mỗi client sử dụng và ghi lại actor hoặc feature làm từng method thay đổi. Với ví dụ enrollment, ma trận ban đầu như sau:

Client

Kiểm tra active

Lưu

Xem theo sinh viên

Tổng hợp doanh thu

Xóa hết hạn

EnrollmentService

Không

Không

Không

StudentDashboardService

Không

Không

Không

Không

RevenueReportJob

Không

Không

Không

Không

DataRetentionJob

Không

Không

Không

Không

Ma trận cho thấy bốn cụm usage. Hai method đầu luôn đi cùng trong luồng đăng ký và cùng thay đổi theo quy tắc enrollment. Ba method còn lại thuộc ba consumer độc lập. Đây là bằng chứng mạnh hơn nhận xét “interface có năm method nên quá lớn”. Nếu ngày mai dashboard và Finance cùng dùng một projection chung, ta có thể xem lại đường cắt; interface không phải cấu trúc bất biến.

Quy trình có thể áp dụng khi refactor một interface đang tồn tại gồm sáu bước. Thứ nhất, tìm mọi injection point và lời gọi method. Thứ hai, nhóm client theo tập method thật sự dùng. Thứ ba, đối chiếu change history và ownership để kiểm tra các nhóm có thay đổi độc lập không. Thứ tư, đặt tên role theo ngôn ngữ domain, tránh các tên mơ hồ như CommonService hoặc BaseOperations. Thứ năm, chuyển từng client sang type hẹp. Cuối cùng, xóa interface cũ khi không còn consumer; nếu là public API hoặc legacy module, giữ một adapter trong giai đoạn chuyển tiếp.

Martin dùng cụm client-specific interface để nhấn mạnh rằng interface được định hình bởi nhu cầu của bên sử dụng. Điều đó không có nghĩa mỗi class client bắt buộc sở hữu một interface riêng. Hai hoặc mười client có cùng nhu cầu ổn định có thể phụ thuộc cùng một role interface. Mục tiêu là tách những nhóm dependency khác nhau, không phải tối đa hóa số lượng type. Trong case ATM của Agile PPP, nhiều interface phía client có thể cùng được một concrete UI implement; bài học quan trọng nằm ở ranh giới nhìn thấy của từng client, không phải số object được tạo.


Case sau refactor: role interface và một implementation đa vai trò

Ta tách EnrollmentOperations theo bốn vai trò đã quan sát. Hai method registration được giữ cùng nhau vì cùng một use case cần cả hai. Ba client còn lại nhận contract riêng. Các interface dùng type domain thay vì expose chi tiết JPA; hướng sở hữu abstraction sẽ được phân tích sâu ở Chương 7.

JavaScript
public interface EnrollmentRegistrationStore {
    boolean existsActiveByStudentAndCourse(UUID studentId, UUID courseId);
    Enrollment save(Enrollment enrollment);
}

public interface StudentEnrollmentQuery {
    List<Enrollment> findByStudentId(UUID studentId);
}

public interface EnrollmentRevenueQuery {
    RevenueSummary summarizeRevenue(LocalDate from, LocalDate to);
}

public interface ExpiredEnrollmentPurger {
    long deleteExpiredBefore(Instant threshold);
}

Client chuyển sang constructor dependency hẹp. Vì mỗi class chỉ có một constructor, Spring có thể autowire mà không cần @Autowired. Dependency giờ diễn đạt cả điều client được phép biết lẫn điều nó không nên biết.

JavaScript
@Service
public class EnrollmentService {
    private final EnrollmentRegistrationStore registrationStore;

    public EnrollmentService(EnrollmentRegistrationStore registrationStore) {
        this.registrationStore = registrationStore;
    }

    @Transactional
    public Enrollment enroll(UUID studentId, UUID courseId) {
        if (registrationStore.existsActiveByStudentAndCourse(studentId, courseId)) {
            throw new DuplicateEnrollmentException(studentId, courseId);
        }
        return registrationStore.save(Enrollment.start(studentId, courseId));
    }
}

@Service
public class StudentDashboardService {
    private final StudentEnrollmentQuery enrollmentQuery;

    public StudentDashboardService(StudentEnrollmentQuery enrollmentQuery) {
        this.enrollmentQuery = enrollmentQuery;
    }

    public List<EnrollmentView> getEnrollments(UUID studentId) {
        return enrollmentQuery.findByStudentId(studentId).stream()
            .map(EnrollmentView::from)
            .toList();
    }
}

@Component
public class RevenueReportJob {
    private final EnrollmentRevenueQuery revenueQuery;

    public RevenueReportJob(EnrollmentRevenueQuery revenueQuery) {
        this.revenueQuery = revenueQuery;
    }

    public RevenueSummary summarize(LocalDate from, LocalDate to) {
        return revenueQuery.summarizeRevenue(from, to);
    }
}

@Component
public class DataRetentionJob {
    private final ExpiredEnrollmentPurger purger;

    public DataRetentionJob(ExpiredEnrollmentPurger purger) {
        this.purger = purger;
    }

    @Transactional
    public long purge(Instant threshold) {
        return purger.deleteExpiredBefore(threshold);
    }
}

Ví dụ giữ bước “kiểm tra rồi lưu” để tập trung vào dependency boundary, không coi nó là cơ chế chống trùng hoàn chỉnh. Trong production, hai request đồng thời vẫn có thể cùng vượt qua bước kiểm tra. Database cần unique constraint phù hợp và application phải chuyển lỗi conflict thành kết quả nghiệp vụ nhất quán. ISP làm contract rõ hơn nhưng không thay thế kiểm soát concurrency hay data integrity.

Tách interface không bắt buộc tách một adapter thành bốn object. Nếu cùng một persistence adapter thực sự có khả năng thực hiện cả bốn vai trò, nó có thể implement cả bốn interface. Spring đăng ký một bean JpaEnrollmentStore; bean đó đáp ứng mọi injection point theo các supertype mà class triển khai.

JavaScript
@Repository
public class JpaEnrollmentStore implements
        EnrollmentRegistrationStore,
        StudentEnrollmentQuery,
        EnrollmentRevenueQuery,
        ExpiredEnrollmentPurger {

    private final EnrollmentJpaRepository repository;

    public JpaEnrollmentStore(EnrollmentJpaRepository repository) {
        this.repository = repository;
    }

    @Override
    public boolean existsActiveByStudentAndCourse(UUID studentId, UUID courseId) {
        return repository.existsByStudentIdAndCourseIdAndStatus(
            studentId, courseId, EnrollmentStatus.ACTIVE);
    }

    @Override
    public Enrollment save(Enrollment enrollment) {
        EnrollmentEntity saved = repository.save(EnrollmentEntity.from(enrollment));
        return saved.toDomain();
    }

    @Override
    public List<Enrollment> findByStudentId(UUID studentId) {
        return repository.findByStudentIdOrderByEnrolledAtDesc(studentId).stream()
            .map(EnrollmentEntity::toDomain)
            .toList();
    }

    @Override
    public RevenueSummary summarizeRevenue(LocalDate from, LocalDate to) {
        return repository.summarizeRevenue(from.atStartOfDay(), to.plusDays(1).atStartOfDay());
    }

    @Override
    public long deleteExpiredBefore(Instant threshold) {
        return repository.deleteByStatusAndCompletedAtBefore(
            EnrollmentStatus.COMPLETED, threshold);
    }
}

Bản sau cải thiện ba điều cụ thể. Thay đổi projection doanh thu chỉ ảnh hưởng EnrollmentRevenueQuery, RevenueReportJob và phần implementation liên quan. EnrollmentService không còn nhìn thấy thao tác báo cáo hoặc xóa. In-memory implementation phục vụ test đăng ký chỉ cần implement EnrollmentRegistrationStore, vì đó là contract thật mà test đang thay thế.

Đánh đổi là số type tăng từ một lên bốn. Lập trình viên phải hiểu tên role và adapter phải khai báo nhiều interface. Chi phí này hợp lý khi hệ thống đã có bốn client với nhu cầu tách biệt; nó không tự động hợp lý nếu ứng dụng mới chỉ có một use case. ISP không loại bỏ complexity mà chuyển complexity từ coupling ngầm sang boundary có tên. Chỉ nên thực hiện giao dịch đó khi boundary giúp giảm một rủi ro đã quan sát hoặc có xác suất đủ cao.


Testability là hệ quả quan sát được, không phải khẩu hiệu

Một role interface hẹp tạo ra test double tập trung. Test EnrollmentService cần mô phỏng việc kiểm tra trùng và lưu enrollment; nó không cần biết cách tổng hợp doanh thu hay xóa dữ liệu. Fake dưới đây là một implementation đầy đủ của EnrollmentRegistrationStore, không chứa method placeholder và không thể bị dùng nhầm cho RevenueReportJob vì type không tương thích.

JavaScript
final class InMemoryEnrollmentRegistrationStore
        implements EnrollmentRegistrationStore {

    private final List<Enrollment> enrollments = new ArrayList<>();

    @Override
    public boolean existsActiveByStudentAndCourse(UUID studentId, UUID courseId) {
        return enrollments.stream().anyMatch(enrollment ->
            enrollment.isActiveFor(studentId, courseId));
    }

    @Override
    public Enrollment save(Enrollment enrollment) {
        enrollments.add(enrollment);
        return enrollment;
    }

    int size() {
        return enrollments.size();
    }
}

Unit test khởi tạo service trực tiếp, không cần Spring context. Điều này phù hợp với cách Chương 2 đã dùng constructor injection: dependency bắt buộc lộ ra trên constructor và có thể được cung cấp bằng một object Java thông thường.

JavaScript
class EnrollmentServiceTest {

    private final UUID studentId = UUID.randomUUID();
    private final UUID courseId = UUID.randomUUID();

    @Test
    void saves_a_new_enrollment() {
        var store = new InMemoryEnrollmentRegistrationStore();
        var service = new EnrollmentService(store);

        Enrollment result = service.enroll(studentId, courseId);

        assertEquals(studentId, result.studentId());
        assertEquals(courseId, result.courseId());
        assertEquals(1, store.size());
    }

    @Test
    void rejects_an_active_duplicate() {
        var store = new InMemoryEnrollmentRegistrationStore();
        var service = new EnrollmentService(store);
        service.enroll(studentId, courseId);

        assertThrows(
            DuplicateEnrollmentException.class,
            () -> service.enroll(studentId, courseId)
        );
        assertEquals(1, store.size());
    }
}

Kết quả hiển thị trên màn hình sẽ là:

EnrollmentServiceTest
  saves_a_new_enrollment()       PASSED
  rejects_an_active_duplicate()  PASSED

Lợi ích quan trọng không nằm ở việc test ngắn hơn vài dòng. Test đã bị cô lập khỏi nhịp thay đổi của Finance và retention. Khi RevenueSummary thêm trường thuế, fake registration và hai test trên không cần sửa. Dependency graph của test phản ánh đúng dependency graph của production client.

Tuy vậy, “dễ mock” không phải lý do đủ để tạo interface cho mọi class. Nếu EnrollmentService có một helper cụ thể, ổn định, không có client role hoặc implementation thay thế nào, tạo EnrollmentHelper chỉ để Mockito chấp nhận có thể làm code phân mảnh mà không giảm coupling có ý nghĩa. Trong chương này, các interface được biện minh trước hết bởi những client boundary có thật; testability là một hệ quả có thể kiểm chứng.

Một test phải stub mười hai method mà chỉ verify một lời gọi là dấu hiệu đáng điều tra. Nó có thể báo abstraction quá rộng, cũng có thể báo test đang đặt sai boundary. Ta cần xem production usage và change history trước khi refactor. ISP cung cấp câu hỏi chẩn đoán, không thay thế phán đoán thiết kế.


ISP trong Spring Data: chọn contract theo client thay vì kế thừa theo quán tính

Spring Data giảm đáng kể boilerplate cho tầng truy cập dữ liệu. Chỉ cần khai báo một interface, framework có thể tạo proxy thực thi CRUD và query suy ra từ tên method. Chính sự tiện lợi này dễ tạo thói quen cho mọi repository kế thừa JpaRepository, dù consumer chỉ cần một hoặc hai operation.

JavaScript
public interface EnrollmentJpaRepository
        extends JpaRepository<EnrollmentEntity, UUID> {

    List<EnrollmentEntity> findByStudentIdOrderByEnrolledAtDesc(UUID studentId);
}

@Service
public class StudentDashboardService {
    private final EnrollmentJpaRepository repository;

    public StudentDashboardService(EnrollmentJpaRepository repository) {
        this.repository = repository;
    }

    public List<EnrollmentView> getEnrollments(UUID studentId) {
        return repository.findByStudentIdOrderByEnrolledAtDesc(studentId).stream()
            .map(EnrollmentEntity::toDomain)
            .map(EnrollmentView::from)
            .toList();
    }
}

StudentDashboardService chỉ truy vấn theo sinh viên, nhưng type của nó còn công bố save, delete, findAll, paging, sorting, Query by Example và các operation JPA như flush hay xóa batch. Code không vì thế lập tức sai; nhiều ứng dụng nội bộ chấp nhận dependency này để đổi lấy tốc độ phát triển. Tuy nhiên, nếu dashboard là boundary cần read-only rõ ràng hoặc repository được chia sẻ cho nhiều module, contract rộng tạo ra capability và coupling không cần thiết.

Ở case role interface trước đó, EnrollmentJpaRepository rộng chỉ là dependency riêng bên trong JpaEnrollmentStore; application client không nhìn thấy nó. Đây là một ranh giới có chủ ý: adapter hạ tầng có thể dùng API framework giàu capability để hoàn thành công việc, trong khi bề mặt trao cho Registration, Dashboard, Finance và retention vẫn hẹp. Vấn đề xuất hiện khi type framework rộng vượt qua adapter và trở thành dependency trực tiếp của client vốn không cần nó.

Spring Data không bắt buộc repository phải kế thừa JpaRepository. Repository<T, ID> là marker interface lưu type domain và ID để framework phát hiện repository. Một interface domain-specific có thể kế thừa Repository rồi chỉ khai báo những method cần công bố. Các signature khớp với CRUD method chuẩn vẫn được Spring Data định tuyến tới base implementation.

JavaScript
public interface StudentEnrollmentRepository
        extends Repository<EnrollmentEntity, UUID> {

    List<EnrollmentEntity> findByStudentIdOrderByEnrolledAtDesc(UUID studentId);
}

Với interface này, Spring vẫn tạo repository proxy, nhưng consumer không thể gọi save() hoặc deleteAllInBatch() vì các method đó không thuộc static type. Đây là selective method exposure: công bố có chọn lọc các operation framework đã hỗ trợ. Nếu nhiều entity cùng dùng một tập CRUD hẹp, ta có thể tạo base interface generic và đánh dấu @NoRepositoryBean để Spring không cố tạo bean trực tiếp cho type chưa xác định entity.

JavaScript
@NoRepositoryBean
public interface SaveAndFindRepository<T, ID> extends Repository<T, ID> {
    <S extends T> S save(S entity);
    Optional<T> findById(ID id);
}

public interface EnrollmentWriteRepository
        extends SaveAndFindRepository<EnrollmentEntity, UUID> {

    boolean existsByStudentIdAndCourseIdAndStatus(
        UUID studentId,
        UUID courseId,
        EnrollmentStatus status
    );
}

Không nên biến SaveAndFindRepository thành một “base repository chuẩn công ty” chứa mọi operation có thể cần. Base interface chỉ có giá trị nếu nhiều repository thật sự chia sẻ cùng contract. Nếu chỉ một repository cần tập method này, khai báo trực tiếp thường rõ hơn và tránh tạo abstraction giả định.

Hierarchy Spring Data hiện hành cũng có một chi tiết dễ bị tài liệu cũ làm hiểu sai. CrudRepository cung cấp CRUD; ListCrudRepository có các operation tương đương nhưng trả List ở những method nhiều kết quả. Từ Spring Data 3.0, PagingAndSortingRepository không còn kế thừa CRUD repository; nếu cần cả CRUD lẫn paging/sorting, interface phải kết hợp cả hai hoặc dùng một extension đã kết hợp chúng. JpaRepository hiện mở rộng ListCrudRepository, ListPagingAndSortingRepositoryQueryByExampleExecutor, đồng thời thêm operation JPA-specific như flush và batch.

Thông tin trên không dẫn tới kết luận “không bao giờ dùng JpaRepository”. Một admin module thực sự cần CRUD đầy đủ, paging, sorting và batch operation có thể dùng JpaRepository như một contract cohesive đối với client của nó. Một dashboard read-only chỉ cần một query thì selective repository hợp lý hơn. ISP yêu cầu chọn abstraction theo client; tên framework không thể quyết định thay đội phát triển.

Cũng cần phân biệt Spring Data repository với application port. StudentEnrollmentRepository ở ví dụ trên vẫn là abstraction thuộc framework persistence vì nó kế thừa type của Spring Data và thao tác EnrollmentEntity. Nếu application service phụ thuộc trực tiếp vào nó, dependency direction là một quyết định kiến trúc khác. Chương 7 sẽ đặt câu hỏi abstraction nên thuộc tầng nào và policy cấp cao có nên biết framework hay không. ISP ở đây chỉ trả lời phần contract nào cần được công bố.


`default method` có cứu được một interface lớn không?

Java cho phép interface có default method. Cơ chế này hữu ích khi một library cần bổ sung hành vi mà vẫn giữ binary compatibility với implementation đã biên dịch trước đó. Implementation cũ nhận hành vi mặc định mà không buộc phải khai báo method mới. Tuy nhiên, compatibility và cohesion là hai vấn đề khác nhau. Method mới vẫn xuất hiện trên type mà mọi client nhìn thấy.

JavaScript
public interface EnrollmentOperations {
    Enrollment save(Enrollment enrollment);

    default RevenueSummary summarizeRevenue(LocalDate from, LocalDate to) {
        throw new UnsupportedOperationException("Revenue report is not supported");
    }
}

Đoạn code trên làm implementation cũ tiếp tục compile, nhưng không làm lời hứa của interface trở nên đúng. Client thấy summarizeRevenue() và có lý do tin rằng mọi EnrollmentOperations thực hiện được operation đó; một số implementation lại ném exception. Nếu exception không được contract công khai như một optional operation, ta vừa giữ nguyên dependency dư của ISP vừa tái tạo vi phạm LSP từ Chương 5.

Default method phù hợp khi hành vi mặc định có nghĩa với mọi implementation trong role và việc bổ sung method vẫn giữ interface cohesive. Một operation được suy ra từ các primitive operation sẵn có có thể là ứng viên, nhưng contract phải bao gồm cả semantics và đặc tính vận hành mà client dựa vào; cách mặc định tải toàn bộ dữ liệu chỉ để đếm có thể đúng về kết quả nhưng không phù hợp với kỳ vọng hiệu năng. Oracle mô tả default method như cơ chế tiến hóa interface và giữ compatibility; cơ chế ngôn ngữ không tự quyết định boundary thiết kế.


Backward force và tác động ở cấp kiến trúc

Trong Agile PPP, Martin mô tả lực thay đổi đi ngược từ client vào interface rồi truyền sang những client khác. Khi một client cần operation mới, cách tiện lợi là thêm operation vào shared interface. Interface thay đổi buộc mọi implementation nhận method; các consumer khác dù không dùng method cũng bị gắn với phiên bản contract mới. Ông gọi hiện tượng này là backward force. Case gốc dùng cửa có bộ hẹn giờ; trong hệ thống của chúng ta, Finance đóng vai trò client tạo lực và EnrollmentOperations là đường truyền lực sang Registration, Dashboard và retention.

Ở một codebase đơn module, IDE và compiler có thể khiến chi phí này trông nhỏ: thêm method, chọn “implement methods”, để placeholder rồi tiếp tục. Placeholder chính là nợ thiết kế. Nó chuyển lỗi từ compile time sang runtime và khiến type không còn phản ánh capability thật. Khi implementation nằm ở repository khác hoặc do đối tác cung cấp, cùng thay đổi có thể buộc phát hành version mới, phối hợp lịch deploy và duy trì compatibility trong thời gian migration.

Tác động còn xuất hiện khi interface mang theo type từ dependency mà client không cần. Nếu EnrollmentOperations trả một JPA-specific projection cho Finance, mọi module import interface có thể bị kéo về phía persistence package. Nếu method mới dùng một thư viện báo cáo lớn, shared API có thể làm dependency đó xuất hiện transitively ở consumer. Tách role interface không chỉ giảm số method; nó ngăn chi tiết của một capability đi qua ranh giới của capability khác.

Mức bảo vệ cần tương xứng với phạm vi. Với private interface trong một module được deploy cùng nhau, compiler và unit test có thể đủ. Với interface công khai cho nhiều team, cần review compatibility, versioning và contract test nghiêm hơn. ISP không yêu cầu mọi boundary có cùng nghi thức; nó yêu cầu nhận diện đúng consumer và chi phí thay đổi của boundary đó.


Dấu hiệu nhận biết khi review code

Không có một metric đơn lẻ chứng minh vi phạm ISP. Những dấu hiệu dưới đây nên được dùng để đặt câu hỏi và thu thập bằng chứng:

  • Implementation có method rỗng, trả null, trả collection rỗng vô điều kiện, ghi comment “not applicable” hoặc ném UnsupportedOperationException.

  • Service chỉ gọi một phần rất nhỏ của dependency rộng, trong khi phần còn lại thuộc actor hoặc quyền truy cập khác.

  • Mock và fake phải stub nhiều method không liên quan chỉ để tạo được test double.

  • Thêm một method báo cáo làm adapter in-memory, luồng ghi hoặc module read-only phải sửa và test lại.

  • Interface có tên mơ hồ như Manager, Operations, CommonService hoặc BaseRepository, còn method chia thành nhiều cụm nghiệp vụ rõ rệt.

  • Read-only flow nhìn thấy operation ghi/xóa; public consumer nhìn thấy method hạ tầng như flush() hoặc batch delete mà use case không cần.

  • Interface kế thừa nhiều interface framework theo thói quen, không có consumer nào sử dụng toàn bộ capability nhận được.

Mỗi dấu hiệu có phản ví dụ hợp lệ. Mock nhiều method đôi khi vì test đang ở sai abstraction; interface tên Operations đôi khi thực sự đại diện một facade cohesive; UnsupportedOperationException có thể thuộc contract của optional operation như Java Collections. Vì vậy, review cần quay về hai dữ liệu: client nào dùng method nào và các method thay đổi vì lý do nào.

Một kỹ thuật thực tế là tìm usage bằng IDE hoặc static analysis, sau đó xem lịch sử commit của interface. Nếu hai nhóm method thường được sửa trong những pull request khác nhau, do team khác nhau sở hữu và có consumer khác nhau, đó là bằng chứng mạnh để tách. Nếu mọi method luôn được một workflow gọi cùng nhau và thay đổi trong cùng feature, số lượng method lớn chưa đủ để kết luận.


Khi nào nên áp dụng, khi nào không nên ép — ISP theo liều lượng

SOLID là công cụ kiểm soát chi phí thay đổi, không phải bộ tiêu chí chấm điểm càng nhiều interface càng tốt. Với ISP, phần mang tính bối cảnh là độ rộng của role, thời điểm tách và mức kiểm chứng. Phần không thể thỏa hiệp là tính trung thực của contract: nếu interface công bố một operation bắt buộc, implementation không được âm thầm biến operation ấy thành no-op hoặc unsupported ngoài lời hứa.

Case nên áp dụng: bốn client có nhu cầu và rủi ro khác nhau

EnrollmentService, StudentDashboardService, RevenueReportJobDataRetentionJob là case nên tách. Bằng chứng không chỉ là chúng dùng các method khác nhau. Chúng phục vụ actor khác nhau, có quyền truy cập khác nhau, thay đổi theo feature khác nhau và cần test double khác nhau. Retention có operation phá hủy dữ liệu; Finance dùng projection và quy tắc kỳ báo cáo; Registration nằm trong transaction nghiệp vụ. Giữ một interface chung khiến thay đổi và capability vượt qua các ranh giới này mà không có lợi ích tương ứng.

Mức bảo vệ hợp lý ở đây là bốn role interface và unit test theo client. Nếu các module vẫn cùng một ứng dụng Spring Boot, chưa cần tách microservice, package riêng hay xây hệ thống versioning. Nếu Finance về sau trở thành service độc lập hoặc public API cho nhiều team, contract báo cáo cần thêm chính sách compatibility và integration test. Thiết kế hiện tại giải quyết rủi ro hiện tại nhưng vẫn để lại trigger rõ cho mức bảo vệ tiếp theo.

Case chưa nên tách thêm: registration là một role cohesive

Sau refactor, có thể tiếp tục tách EnrollmentRegistrationStore thành ActiveEnrollmentCheckerEnrollmentSaver, mỗi interface một method. Trong bối cảnh hiện tại, việc đó chưa tạo ra boundary có giá trị. Cùng một EnrollmentService gọi cả hai operation trong một use case; chúng cùng thay đổi khi quy tắc đăng ký hoặc persistence của enrollment thay đổi; cùng adapter và cùng fake thực hiện cả hai một cách hợp lệ. Hai constructor dependency thay cho một làm tăng số type, tên và điểm điều hướng nhưng không giảm một coupling đã quan sát.

Quyết định giữ chung không có nghĩa hai method phải ở cùng nhau mãi mãi. Trigger xem xét lại xuất hiện nếu một client mới chỉ cần kiểm tra eligibility mà không được phép lưu, nếu thao tác kiểm tra chuyển sang một nguồn dữ liệu khác, hoặc nếu change history cho thấy hai capability có ownership và nhịp release tách biệt. Khi bằng chứng đổi, đường cắt nên đổi theo.

Câu hỏi

Bằng chứng nghiêng về tách

Bằng chứng nghiêng về giữ

Client usage

Nhiều client dùng các tập method gần như rời nhau

Cùng nhóm client dùng phần lớn contract

Lý do thay đổi

Actor, quyền, ownership hoặc nhịp release khác nhau

Method thay đổi cùng một capability/use case

Implementation

Có no-op, unsupported hoặc adapter chỉ hỗ trợ một phần

Mọi implementation có behavior hợp lệ

Rủi ro

Public API, nhiều team, operation phá hủy hoặc dependency nặng

Code nội bộ, dễ đổi, cùng release unit

Chi phí abstraction

Tên role rõ, giảm test/wiring bị ảnh hưởng

Interface explosion, tên gượng, navigation tăng

YAGNI nhắc ta không xây capability interface cho client chỉ mới được phỏng đoán. Rule of Three nhắc rằng abstraction thường tốt hơn khi đã có đủ ví dụ để nhìn thấy pattern. Cảnh báo wrong abstraction nhắc rằng một đường tách đặt sai có thể đắt hơn một ít duplication tạm thời. Ba ý này không phủ nhận ISP; chúng giúp chọn thời điểm và độ chi tiết. Refactoring an toàn dựa trên code dễ thay đổi và test tốt thường đáng tin hơn thiết kế trước mười role cho tương lai chưa biết.

Có thể dùng thang bảo vệ sau. Với code nội bộ nhỏ và một client, giữ interface cohesive hiện tại, ghi nhận usage và chưa tách. Khi xuất hiện client thứ hai với tập method khác biệt, tách hai hoặc ba role đã quan sát và thêm test theo boundary. Với public SDK, multi-team integration hoặc operation nhạy cảm, thiết kế contract hẹp ngay từ đầu và quản lý compatibility nghiêm hơn. Mức bảo vệ thay đổi theo rủi ro; tiêu chuẩn contract trung thực không thay đổi.

Ghi chú

Linh hoạt ở granularity và thời điểm tách, không linh hoạt ở việc hứa một operation rồi để implementation âm thầm không hỗ trợ. “Interface nhỏ” là phương tiện; “client chỉ phụ thuộc vào contract cần thiết và có nghĩa” mới là mục tiêu.

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

“Interface chỉ nên có một method.” Functional interface một method có vai trò riêng trong Java, nhưng ISP không đặt giới hạn đó. Một role có thể gồm nhiều method nếu client dùng chúng cùng nhau và chúng có cohesion cao.

“Tách CRUD thành bốn interface là luôn đạt ISP.” CreateRepository, ReadRepository, UpdateRepository, DeleteRepository chỉ hợp lý nếu client và change pattern thực sự đi theo bốn nhóm đó. Nhiều use case cần đọc rồi ghi trong cùng transaction; tách theo tên kỹ thuật có thể kém rõ hơn tách theo role nghiệp vụ.

“Một class implement nhiều interface là thiết kế tệ.” ISP tách cách client nhìn object, không cấm object cung cấp nhiều capability. Một adapter JPA implement nhiều role là hợp lệ nếu mọi operation đều có nghĩa và class vẫn có cohesion ở cấp implementation.

“Dùng `JpaRepository` luôn vi phạm ISP.” JpaRepository là contract rộng có chủ ý. Nó phù hợp khi client thật sự cần tập capability ấy; nó không phù hợp chỉ vì là lựa chọn mặc định thuận tiện. Hãy chọn theo usage, phạm vi chia sẻ và rủi ro.

“Spring Data đã tách hierarchy nên ứng dụng tự động tuân thủ ISP.” Framework cung cấp nhiều interface và selective exposure, nhưng ứng dụng vẫn quyết định repository nào được kế thừa và type nào được inject vào client. Tool không thay thế thiết kế dependency.

“Thêm default method là đã giải quyết compatibility và ISP.” Default method có thể giữ implementation cũ chạy, nhưng client vẫn nhìn thấy method mới. Nếu hành vi mặc định không có nghĩa hoặc method phục vụ role khác, vấn đề thiết kế còn nguyên.

“Tạo interface luôn giúp test tốt hơn.” Interface hẹp tại boundary thật giúp test tập trung. Interface chỉ được tạo để mock một helper cụ thể, không có role hay variation thật, có thể làm thiết kế khó đọc hơn mà không giảm rủi ro.


Từ LSP qua ISP đến DIP

LSP yêu cầu implementation giữ behavioral contract của abstraction. Khi một implementation liên tục phải ném unsupported cho một phần contract, nguyên nhân thường là abstraction đã gom capability không thuộc mọi subtype. ISP sửa vấn đề từ phía client: tách contract để mỗi implementation và consumer chỉ nhận vai trò có nghĩa. Vì thế, LSP và ISP hỗ trợ nhau — một nguyên tắc kiểm tra khả năng thay thế, nguyên tắc kia định hình bề mặt dependency.

Sau khi tách được EnrollmentRegistrationStore, câu hỏi tiếp theo là interface ấy nên nằm ở đâu và ai sở hữu nó. Nếu application policy phụ thuộc trực tiếp vào Spring Data, chi tiết persistence vẫn hướng dependency vào phần cấp thấp. Dependency Inversion Principle ở Chương 7 sẽ chỉ ra cách để policy cấp cao định nghĩa abstraction cần thiết, còn adapter hạ tầng phụ thuộc ngược vào abstraction đó. ISP trả lời phụ thuộc vào phần nào; DIP trả lời dependency hướng về đâu.


Tóm tắt

  • ISP yêu cầu client không bị ép phụ thuộc vào method nó không sử dụng; trọng tâm là dependency, không phải số method.

  • Fat Interface trộn nhiều nhóm client hoặc nhiều lý do thay đổi. Role Interface nhóm method theo capability mà client thực sự cần.

  • Một Spring bean có thể implement nhiều role interface; tách contract không đồng nghĩa phải tách thành nhiều object.

  • Interface hẹp giảm bán kính thay đổi, giới hạn capability lộ ra và tạo test double tập trung.

  • Spring Data cho phép chọn từ Repository, các CRUD/sorting interface đến JpaRepository, đồng thời hỗ trợ selective method exposure. Không có một lựa chọn đúng cho mọi client.

  • default method có thể hỗ trợ compatibility nhưng không tự sửa dependency dư hoặc behavior không hợp lệ.

  • Chỉ tách khi có bằng chứng từ client usage, change history, implementation và rủi ro; không tạo one-method interface theo phản xạ.

  • Linh hoạt ở mức phân tách, nhưng contract đã công bố phải trung thực và có nghĩa đối với mọi implementation của role.


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

Phần bài tập cuối chương gồm câu hỏi khái niệm, code-spotting, refactor role interface, kiểm tra Spring Data và một tình huống yêu cầu bạn bảo vệ quyết định không tách interface quá mức.

Hỏi đáp

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