Soldemy
0%

SOLID trong Java Spring Boot

Dependency Inversion Principle (DIP)

29 phút đọc

Dependency Inversion Principle (DIP)

Cuối Chương 6, EnrollmentService đã phụ thuộc vào một contract vừa đủ thay vì toàn bộ khả năng của persistence layer. Tuy nhiên, độ rộng của contract chỉ là một nửa câu chuyện. Nếu interface ấy kế thừa Spring Data, trả về JPA entity và nằm trong package infrastructure, application policy vẫn phải hiểu chi tiết database. ISP trả lời client nên nhìn thấy bao nhiêu; DIP tiếp tục bằng câu hỏi quan trọng hơn: contract thuộc về phía nào, và source dependency phải hướng về đâu?

Hãy xét chức năng gửi lời nhắc cho học viên có enrollment sắp hết hạn. Phiên bản đầu được giao nhanh: một Spring service truy vấn EnrollmentJpaRepository, lấy EnrollmentEntity, tự tính thời điểm hiện tại rồi gọi SDK của nhà cung cấp SMS. Code chạy ổn trong nhiều tháng. Đến khi schema enrollment được tối ưu và nhà cung cấp SMS thay SDK, class chứa quy tắc “nhắc trước hạn ba ngày, không nhắc enrollment đã hoàn tất” phải sửa theo cả hai thay đổi kỹ thuật. Unit test của quy tắc nghiệp vụ cũng phải biết query JPA và request object của vendor.

Database và SMS provider không có lỗi; chúng đang làm đúng công việc của hạ tầng. Vấn đề là các chi tiết dễ biến động đang định hình source code của chính sách ổn định hơn. Dependency Inversion Principle sắp xếp lại mối quan hệ đó: application diễn đạt điều nó cần bằng abstraction của chính nó, còn adapter hạ tầng phụ thuộc vào abstraction để thực hiện nhu cầu ấy.

Ghi chú

DIP không loại bỏ dependency. Nó sắp xếp dependency để chính sách nghiệp vụ không phải chạy theo database, framework hoặc vendor SDK.

DIP thật sự nói gì

Dependency Inversion Principle có hai mệnh đề bổ sung cho nhau. Thứ nhất, high-level module không nên phụ thuộc vào low-level module; cả hai nên phụ thuộc vào abstraction. Thứ hai, abstraction không nên phụ thuộc vào detail; detail nên phụ thuộc vào abstraction. Nếu chỉ nhớ câu “hãy dùng interface”, ta bỏ mất phần quan trọng nhất: mức trừu tượng, hướng dependency và quyền sở hữu contract.

High-level module là phần code chứa policy — quyết định có ý nghĩa đối với nghiệp vụ. Trong case này, EnrollmentReminderService quyết định enrollment nào đến hạn, trạng thái nào được nhắc và mỗi lần chạy xử lý kết quả ra sao. Low-level module là mechanism dùng để thực thi policy: JPA query dữ liệu, SDK gửi SMS, HTTP client gọi nhà cung cấp. “High” và “low” không nói về chức danh của team, số tầng trong call stack hay vị trí class trên sơ đồ.

Một @RestController thường xuất hiện ở đầu call chain nhưng có thể chỉ chuyển HTTP request thành command; nó là delivery detail, không phải policy cao nhất. Ngược lại, một rule quyết định học viên có đủ điều kiện nhận chứng chỉ có thể nằm sâu trong call chain nhưng vẫn là high-level policy. Cách nhận diện tốt hơn là hỏi: phần code nào chứa quyết định mà doanh nghiệp muốn giữ ổn định khi công nghệ bên ngoài thay đổi?

Mệnh đề thứ hai ngăn một cách “sửa” hời hợt. Nếu ta tạo SmsVendorClientInterface nhưng interface vẫn nhận SmsVendorRequest, trả SmsVendorResponse và dùng khái niệm template ID riêng của nhà cung cấp, abstraction đó đang được định hình bởi detail. Application chỉ chuyển từ phụ thuộc concrete vendor class sang phụ thuộc một bản sao API của vendor. Mock có thể dễ hơn, nhưng policy chưa thật sự được bảo vệ.

Robert C. Martin cũng cảnh báo rằng cách hiểu “không bao giờ phụ thuộc concrete” là không thực tế. Java code phải tạo object; String, collection, value object và nhiều platform class cụ thể đủ ổn định để dùng trực tiếp. Điều cần kiểm soát là dependency vào concrete dễ biến động: database API, external service, file format, UI framework hoặc implementation thay đổi theo ownership và release cadence khác.

Ghi chú

High-level không có nghĩa “được gọi trước”. Nó là phần chứa policy cần được bảo vệ. Abstraction tốt nói bằng ngôn ngữ của policy ấy, không phải ngôn ngữ của công cụ đang phục vụ nó.

Ba loại mũi tên thường bị trộn lẫn

Để hiểu từ “inversion”, cần tách ba quan hệ. Runtime control flow cho biết lúc chạy object nào gọi object nào. Source dependency cho biết source code phải import, compile và hiểu type của module nào. Construction dependency cho biết thành phần nào biết các concrete class để tạo object graph. Ba quan hệ có thể cùng tồn tại nhưng không nhất thiết cùng hướng.

Sau khi áp dụng DIP, runtime flow vẫn đi từ EnrollmentReminderService tới JpaDueEnrollmentAdapter, rồi đến database. Không có chuyện business rule tự truy vấn dữ liệu mà không gọi hạ tầng. Điểm đảo chiều nằm ở source: application service import DueEnrollmentQuery; JPA adapter ở infrastructure import và implement chính interface đó. Mũi tên source của infrastructure hướng vào contract do application sở hữu, ngược với kiểu layered code truyền thống nơi application import thẳng repository framework.

Construction dependency lại được gom tại bootstrap. ReminderConfiguration biết JpaDueEnrollmentAdapter, SmsReminderAdapterEnrollmentReminderService để lắp chúng thành object graph. Đây là Composition Root: vị trí gần điểm khởi động ứng dụng nơi concrete object được tạo và nối với nhau. Concrete dependency không biến mất; nó được cô lập để phần còn lại không cần biết.


Case trước refactor: dependency đúng cú pháp, sai hướng thiết kế

Phiên bản dưới đây không phải code cố tình viết tệ. Nó là kết quả tự nhiên khi một feature được xây từ các API sẵn có: Spring Data đã có repository, SDK đã có client, vì vậy service gọi trực tiếp cả hai. Constructor injection được dùng đúng cú pháp, nhưng dependency của application vẫn hướng ra detail.

JavaScript
@Service
public class EnrollmentReminderService {

    private final EnrollmentJpaRepository repository;
    private final SmsVendorClient smsVendorClient;

    public EnrollmentReminderService(
            EnrollmentJpaRepository repository,
            SmsVendorClient smsVendorClient) {
        this.repository = repository;
        this.smsVendorClient = smsVendorClient;
    }

    public int remindEnrollmentsExpiringSoon() {
        Instant now = Instant.now();
        Instant deadline = now.plus(3, ChronoUnit.DAYS);

        List<EnrollmentEntity> entities =
                repository.findByExpiresAtBetweenAndCompletedFalse(now, deadline);

        for (EnrollmentEntity entity : entities) {
            SmsVendorRequest request = new SmsVendorRequest(
                    entity.getStudentPhone(),
                    "REMINDER_TEMPLATE_SID",
                    Map.of("courseName", entity.getCourseName(),
                           "expiresAt", entity.getExpiresAt().toString())
            );
            smsVendorClient.send(request);
        }
        return entities.size();
    }
}

Ghi chú

- Business rule biết query naming convention và `EnrollmentEntity` của persistence. - Application biết request, template identifier và call chain của vendor SDK. - `Instant.now()` là hidden dependency, làm test biên thời gian thiếu ổn định. - Đổi schema, provider hoặc cách biểu diễn thời gian đều buộc sửa cùng class chứa policy.

Constructor injection ở đây vẫn có ích: dependency hiện rõ và có thể được thay trong test nếu library hỗ trợ. Nhưng đó là lợi ích của DI, chưa phải bằng chứng rằng thiết kế đạt DIP. Service vẫn phụ thuộc vào abstraction level của JPA và SMS vendor. Một kỹ thuật lắp ráp tốt không tự động sửa một boundary đặt sai.

Test cũng phản ánh coupling này. Để kiểm tra rule “chỉ nhắc enrollment chưa hoàn tất trong ba ngày tới”, test phải dựng EnrollmentEntity, biết method Spring Data cụ thể, mock call chain của SDK và kiểm soát system time gián tiếp. Khi SDK đổi từ synchronous response sang builder hoặc async API, test business rule hỏng dù rule không đổi. Test đang xác nhận chi tiết integration nhiều hơn chính sách.

Trong code doanh nghiệp, hậu quả không chỉ là test dài. Vendor exception có thể lan vào controller; application module kéo theo SDK nặng; người mới phải hiểu schema và API của provider trước khi sửa một rule đơn giản. Khi nhiều feature đi theo cùng cách, database và vendor trở thành ngôn ngữ mặc định của toàn codebase, khiến mọi thay đổi kỹ thuật có bán kính lớn.


Một interface chưa chắc là một abstraction tốt

Cách refactor nhanh nhất thường là Extract Interface từ SmsVendorClient. IDE tạo interface có cùng method, concrete client implement nó, rồi service inject interface. Source code không còn nhắc concrete class nhưng vẫn nhắc toàn bộ mô hình của vendor. Đây là interface kỹ thuật, không phải port của application.

JavaScript
// Đã có interface, nhưng contract vẫn nói bằng ngôn ngữ vendor.
public interface SmsVendorClientInterface {
    SmsVendorResponse createMessage(
            String messagingServiceSid,
            String contentTemplateSid,
            Map<String, String> variables,
            String destinationPhone);
}

Một port tốt bắt đầu từ câu hỏi use case cần gì. Reminder policy không cần biết service identifier, template identifier hay response riêng của nhà cung cấp. Nó cần gửi một lời nhắc enrollment có người nhận, khóa học và hạn hoàn tất; sau đó cần biết gửi thành công hay thất bại theo một contract có nghĩa với batch. Vì vậy, tên và signature nên phản ánh capability đó.

JavaScript
// ReminderSender.java
public interface ReminderSender {
    DeliveryResult send(EnrollmentReminder reminder);
}

public record EnrollmentReminder(
        UUID enrollmentId,
        String destination,
        String courseName,
        Instant expiresAt) {
}

public enum DeliveryResult {
    DELIVERED,
    TEMPORARILY_FAILED
}

Port này không cố che toàn bộ messaging platform bằng send(Object payload). Một abstraction quá tổng quát làm mất semantics và buộc từng caller tự thỏa thuận bằng convention. ReminderSender hẹp theo client của nó — bài học ISP — và công bố behavior mà mọi adapter phải giữ — bài học LSP. Nếu email và SMS có semantics khác đến mức không thể giữ cùng contract, chúng không nên bị ép vào cùng port chỉ để có polymorphism.


Thiết kế port từ use case, không từ công cụ

Một quy trình thực tế để tìm abstraction gồm sáu bước. Đầu tiên, khoanh policy cần bảo vệ. Tiếp theo, liệt kê đúng dữ liệu và operation policy dùng. Sau đó đặt tên bằng ngôn ngữ domain, loại type của ORM/vendor khỏi signature, công bố semantics thành công và thất bại, rồi đặt port ở phía application. Adapter được viết sau để dịch từ công nghệ cụ thể sang contract ấy.

Với dữ liệu, service không cần khả năng CRUD tổng quát. Nó cần tìm những enrollment chưa hoàn tất và đến hạn trước một thời điểm. Contract dưới đây diễn đạt đúng query của use case, đồng thời trả một projection nhỏ thay vì JPA entity.

JavaScript
// DueEnrollmentQuery.java
public interface DueEnrollmentQuery {
    List<DueEnrollment> findIncompleteExpiringBetween(
            Instant fromInclusive,
            Instant toExclusive);
}

public record DueEnrollment(
        UUID enrollmentId,
        String studentPhone,
        String courseName,
        Instant expiresAt) {
}

Tên DueEnrollmentQuery có chủ ý. Method findIncompleteExpiringBetween() đặt điều kiện “chưa hoàn tất và hết hạn trong khoảng” vào contract application; câu lệnh JPA chỉ hiện thực hóa điều kiện đó. EnrollmentRepository dễ khiến port tích lũy CRUD và mọi nhu cầu liên quan entity. Query-specific port nói rõ capability mà policy cần và cho phép adapter tối ưu projection mà không làm lộ JPA. Điều đó không có nghĩa mọi use case phải có một repository interface riêng; chỉ tách khi client role và change pattern biện minh cho boundary.

Thời gian cũng là dependency. Gọi Instant.now() trực tiếp khiến kết quả phụ thuộc đồng hồ hệ thống. Java đã cung cấp java.time.Clock, một abstraction ổn định và đủ đúng cho nhu cầu lấy thời gian kiểm thử được; không cần phát minh TimeProvider chỉ để chứng minh DIP. Đây là ví dụ quan trọng: ưu tiên abstraction phù hợp đã tồn tại hơn việc tăng số type của dự án.

Ghi chú

Đừng thiết kế port bằng cách chép lại API của adapter. Hãy viết contract tối thiểu mà policy cần, dùng từ vựng của use case và công bố semantics mà client có thể dựa vào.

Case sau refactor: policy đứng yên, detail xoay quanh nó

Sau refactor, application service là một Java class thuần. Nó điều phối policy bằng ba dependency được công bố ở constructor: query port, sender port và clock. Không type nào của JPA, Spring hay SMS SDK xuất hiện trong source của class.

JavaScript
public final class EnrollmentReminderService {

    private static final Duration REMINDER_WINDOW = Duration.ofDays(3);

    private final DueEnrollmentQuery dueEnrollmentQuery;
    private final ReminderSender reminderSender;
    private final Clock clock;

    public EnrollmentReminderService(
            DueEnrollmentQuery dueEnrollmentQuery,
            ReminderSender reminderSender,
            Clock clock) {
        this.dueEnrollmentQuery = dueEnrollmentQuery;
        this.reminderSender = reminderSender;
        this.clock = clock;
    }

    public ReminderBatchResult remindEnrollmentsExpiringSoon() {
        Instant now = clock.instant();
        Instant deadline = now.plus(REMINDER_WINDOW);
        List<DueEnrollment> dueEnrollments =
                dueEnrollmentQuery.findIncompleteExpiringBetween(now, deadline);

        int delivered = 0;
        int failed = 0;

        for (DueEnrollment enrollment : dueEnrollments) {
            EnrollmentReminder reminder = new EnrollmentReminder(
                    enrollment.enrollmentId(),
                    enrollment.studentPhone(),
                    enrollment.courseName(),
                    enrollment.expiresAt());

            if (reminderSender.send(reminder) == DeliveryResult.DELIVERED) {
                delivered++;
            } else {
                failed++;
            }
        }
        return new ReminderBatchResult(dueEnrollments.size(), delivered, failed);
    }
}

public record ReminderBatchResult(int selected, int delivered, int failed) {
}

Business rule giờ có thể đọc độc lập: cửa sổ ba ngày nằm trong policy; clock được cung cấp; kết quả batch phân biệt số được chọn, số gửi thành công và thất bại tạm thời. Nếu yêu cầu đổi thành năm ngày hoặc bỏ qua một trạng thái mới, thay đổi nằm ở application. Nếu database chuyển từ PostgreSQL sang một read model hoặc SMS chuyển provider, adapter đổi mà contract có thể giữ nguyên.

JPA adapter thực hiện query và mapping. Spring Data repository là chi tiết nội bộ của infrastructure package, không được tiêm trực tiếp vào application service.

JavaScript
@Repository
final class JpaDueEnrollmentAdapter implements DueEnrollmentQuery {

    private final SpringDataEnrollmentRepository repository;

    JpaDueEnrollmentAdapter(SpringDataEnrollmentRepository repository) {
        this.repository = repository;
    }

    @Override
    public List<DueEnrollment> findIncompleteExpiringBetween(
            Instant fromInclusive,
            Instant toExclusive) {
        return repository
                .findReminderCandidates(fromInclusive, toExclusive)
                .stream()
                .map(row -> new DueEnrollment(
                        row.enrollmentId(),
                        row.studentPhone(),
                        row.courseName(),
                        row.expiresAt()))
                .toList();
    }
}

interface SpringDataEnrollmentRepository
        extends Repository<EnrollmentEntity, UUID> {

    @Query("""
            select new com.soldemy.infrastructure.persistence.ReminderCandidateRow(
                e.id, s.phone, c.name, e.expiresAt)
            from EnrollmentEntity e
            join e.student s
            join e.course c
            where e.completed = false
              and e.expiresAt >= :from
              and e.expiresAt < :to
            """)
    List<ReminderCandidateRow> findReminderCandidates(
            @Param("from") Instant from,
            @Param("to") Instant to);
}

Projection ReminderCandidateRow vẫn có thể là type infrastructure vì nó không đi qua port. Mapping không phải boilerplate vô nghĩa: nó là điểm chuyển ngôn ngữ và ngăn schema persistence trở thành public model của application. Tuy nhiên, mapping có chi phí. Với một CRUD module nội bộ không cần cách ly, trả entity trực tiếp có thể là trade-off chấp nhận được; không nên tạo mapping layer theo phản xạ.

SMS adapter chịu trách nhiệm chuyển request của application sang vendor API và quy đổi lỗi về semantics của port.

JavaScript
@Component
final class SmsReminderAdapter implements ReminderSender {

    private final SmsVendorClient smsVendorClient;
    private final String templateSid;

    SmsReminderAdapter(
            SmsVendorClient smsVendorClient,
            @Value("${reminder.sms.template-sid}") String templateSid) {
        this.smsVendorClient = smsVendorClient;
        this.templateSid = templateSid;
    }

    @Override
    public DeliveryResult send(EnrollmentReminder reminder) {
        SmsVendorRequest request = new SmsVendorRequest(
                reminder.destination(),
                templateSid,
                Map.of("courseName", reminder.courseName(),
                       "expiresAt", reminder.expiresAt().toString()));
        try {
            smsVendorClient.send(request);
            return DeliveryResult.DELIVERED;
        } catch (TemporarySmsException exception) {
            return DeliveryResult.TEMPORARILY_FAILED;
        }
    }
}

Ví dụ cố ý chỉ map lỗi tạm thời. Lỗi cấu hình, authentication hoặc request không hợp lệ không nên bị biến thành TEMPORARILY_FAILED một cách im lặng vì retry sẽ không sửa được chúng. Trong production, contract cần phân biệt lỗi retryable và permanent, kèm logging, metrics, idempotency hoặc outbox nếu mức tin cậy yêu cầu. DIP chỉ đặt boundary; nó không tự giải quyết distributed failure.

Ghi chú

Lợi ích của bản sau không nằm ở số interface. Policy không import framework/vendor; adapter chịu trách nhiệm dịch; constructor công bố dependency; mỗi phần có thể thay đổi và được kiểm thử ở mức phù hợp. Đánh đổi là thêm type, mapping, wiring và contract cần duy trì.

Spring lắp ráp object graph, không quyết định kiến trúc thay ta

Spring Framework mô tả DI là quá trình object khai báo dependency qua constructor, factory method hoặc property, rồi container cung cấp dependency khi tạo bean. Framework khuyến khích constructor injection cho dependency bắt buộc vì object có thể được tạo ở trạng thái hợp lệ và field có thể là final. Cơ chế này phù hợp với DIP, nhưng container không biết abstraction nào nên thuộc application.

Nếu muốn application core không phụ thuộc annotation Spring, composition root có thể đăng ký service một cách tường minh:

JavaScript
@Configuration
class ReminderConfiguration {

    @Bean
    Clock reminderClock() {
        return Clock.systemUTC();
    }

    @Bean
    EnrollmentReminderService enrollmentReminderService(
            DueEnrollmentQuery dueEnrollmentQuery,
            ReminderSender reminderSender,
            Clock reminderClock) {
        return new EnrollmentReminderService(
                dueEnrollmentQuery,
                reminderSender,
                reminderClock);
    }
}

Configuration class biết concrete beans do component scanning hoặc các @Bean khác cung cấp. Application service chỉ biết port. Khi có hai ReminderSender, chẳng hạn SMS và email, composition root phải chọn rõ bằng @Qualifier, @Primary hoặc tạo một composite có semantics đã định nghĩa. Việc có nhiều bean không phải lý do để service tự tra cứu container.

Một lựa chọn khác là gắn @Service lên EnrollmentReminderService và để component scanning tạo bean. Cách này giảm configuration code và hoàn toàn thực dụng trong nhiều modular monolith Spring Boot. Đổi lại, source của application phụ thuộc annotation Spring. Nếu mục tiêu là core chạy độc lập framework, shared library hoặc module có boundary được kiểm soát nghiêm, core thuần Java đáng chi phí hơn. Nếu Spring là platform ổn định của sản phẩm và annotation không làm rule khó test, dependency ấy có thể được chấp nhận có chủ đích.

Đây là nơi lý thuyết ở trường và code production thường khác nhau. Class diagram có thể khuyến khích mọi dependency đều đi qua interface và mọi layer hoàn toàn độc lập. Trong sản phẩm, mỗi boundary có chi phí build, navigation và vận hành. Kiến trúc tốt không phải kiến trúc có nhiều mũi tên đảo nhất; đó là kiến trúc đặt boundary tại nơi rủi ro thay đổi biện minh cho chi phí.


DIP, DI và IoC không phải ba tên của cùng một việc

Ba khái niệm thường xuất hiện cùng nhau trong Spring nên dễ bị đồng nhất. Bảng dưới đây tách câu hỏi mà mỗi khái niệm trả lời:

Khái niệm

Câu hỏi chính

Ví dụ trong chương

DIP

Source dependency và abstraction phải hướng về policy nào?

Application sở hữu ReminderSender; SMS adapter implement port.

DI

Object nhận collaborator bằng cách nào?

Constructor nhận ReminderSenderClock.

IoC

Ai giữ quyền tạo, gọi hoặc quản lý lifecycle?

Spring container tạo bean và lắp object graph.

Dependency Injection là một pattern cung cấp dependency từ bên ngoài thay vì để object tự tạo. Inversion of Control rộng hơn DI: framework có thể nắm quyền gọi lifecycle, dispatch request hoặc thực thi callback. DIP lại quan tâm source dependency giữa policy và detail. Fowler trình bày DI và Service Locator như hai cách tách configuration khỏi việc sử dụng service; điều đó không biến cả hai thành DIP.

JavaScript
// Có constructor injection, nhưng policy vẫn phụ thuộc vendor client.
public EnrollmentReminderService(SmsVendorClient smsVendorClient) {
    this.smsVendorClient = smsVendorClient;
}

Đoạn code trên có DI vì dependency được truyền từ ngoài. Spring có thể thực hiện IoC bằng cách tạo và inject bean. Nhưng nếu EnrollmentReminderService dùng type và semantics của vendor, DIP vẫn chưa đạt mục tiêu. Ngược lại, một ứng dụng không dùng DI container vẫn có thể tuân DIP bằng cách tự tạo adapter ở main() rồi truyền chúng vào constructor.


Service Locator: dependency bị giấu sau một lời gọi tiện lợi

Service Locator cho phép object yêu cầu dependency từ registry tại runtime. Trong Spring, dạng thường gặp là inject ApplicationContext rồi gọi getBean() bên trong business method. Code có vẻ linh hoạt vì không nhắc concrete implementation, nhưng dependency thật không còn xuất hiện trên constructor.

JavaScript
@Service
public class EnrollmentReminderService {

    private final ApplicationContext context;

    public EnrollmentReminderService(ApplicationContext context) {
        this.context = context;
    }

    public void remind(DueEnrollment enrollment) {
        ReminderSender sender = context.getBean(ReminderSender.class);
        sender.send(toReminder(enrollment));
    }
}

Constructor nói rằng class chỉ cần ApplicationContext, trong khi behavior thật cần ReminderSender. Lỗi thiếu hoặc trùng bean chuyển từ thời điểm construction sang runtime; IDE khó chỉ ra collaborator; unit test phải tạo registry hoặc mock container. Application policy cũng phụ thuộc framework API. Constructor injection trực tiếp làm dependency hiển thị, khiến object không thể được tạo hợp lệ nếu thiếu collaborator.

Không nên biến kết luận này thành “Service Locator luôn sai”. Fowler chỉ ra locator vẫn có thể được cấu hình và thay bằng test implementation. Framework internals, plugin discovery hoặc legacy boundary đôi khi cần lookup động. Điều cần kiểm soát là phạm vi: lookup nên nằm ở bootstrap/adapter chuyên trách, không phân tán trong policy. Khi tập implementation đã biết lúc lắp ứng dụng, inject List<ReminderSender> hoặc một strategy registry có contract cụ thể thường minh bạch hơn để service tự truy cập container.


Testability là hệ quả quan sát được của dependency direction

Khi port mô tả đúng nhu cầu application, unit test có thể dùng test double nhỏ và không khởi động Spring context. Clock.fixed() làm biên thời gian deterministic; fake query kiểm soát input; recording sender lưu lại interaction cần xác nhận.

JavaScript
class EnrollmentReminderServiceTest {

    private static final Instant NOW = Instant.parse("2026-08-06T09:00:00Z");

    @Test
    void sends_reminders_for_enrollments_due_inside_the_window() {
        DueEnrollment due = new DueEnrollment(
                UUID.fromString("b0adad00-0000-0000-0000-000000000001"),
                "+84901234567",
                "Java Spring Boot thực chiến",
                NOW.plus(2, ChronoUnit.DAYS));

        CapturingDueEnrollmentQuery query =
                new CapturingDueEnrollmentQuery(List.of(due));
        RecordingReminderSender sender = new RecordingReminderSender();
        Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);

        EnrollmentReminderService service =
                new EnrollmentReminderService(query, sender, clock);

        ReminderBatchResult result = service.remindEnrollmentsExpiringSoon();

        assertEquals(new ReminderBatchResult(1, 1, 0), result);
        assertEquals(List.of(due.enrollmentId()), sender.sentEnrollmentIds());
        assertEquals(NOW, query.requestedFrom());
        assertEquals(NOW.plus(3, ChronoUnit.DAYS), query.requestedTo());
    }

    private static final class CapturingDueEnrollmentQuery
            implements DueEnrollmentQuery {
        private final List<DueEnrollment> result;
        private Instant requestedFrom;
        private Instant requestedTo;

        private CapturingDueEnrollmentQuery(List<DueEnrollment> result) {
            this.result = result;
        }

        @Override
        public List<DueEnrollment> findIncompleteExpiringBetween(
                Instant fromInclusive,
                Instant toExclusive) {
            requestedFrom = fromInclusive;
            requestedTo = toExclusive;
            return result;
        }

        Instant requestedFrom() { return requestedFrom; }
        Instant requestedTo() { return requestedTo; }
    }

    private static final class RecordingReminderSender
            implements ReminderSender {
        private final List<UUID> sentIds = new ArrayList<>();

        @Override
        public DeliveryResult send(EnrollmentReminder reminder) {
            sentIds.add(reminder.enrollmentId());
            return DeliveryResult.DELIVERED;
        }

        List<UUID> sentEnrollmentIds() {
            return List.copyOf(sentIds);
        }
    }
}

Test này kiểm tra cửa sổ ba ngày cùng mapping từ candidate sang reminder mà không biết JPA hay SMS vendor. Nó chạy nhanh và thất bại vì business behavior, không vì container configuration. Tuy nhiên, test dễ viết không chứng minh toàn hệ thống đúng. JpaDueEnrollmentAdapter vẫn cần integration test với query thật; SmsReminderAdapter cần test mapping/error semantics; mọi implementation phải giữ contract của port.

Chương 5 quay lại ở đây. Nếu fake luôn trả DELIVERED nhưng production adapter ném vendor exception cho cùng tình huống mà contract yêu cầu trả TEMPORARILY_FAILED, hai implementation không substitutable. DIP tạo hướng dependency; LSP giữ boundary trung thực; Chương 8 sẽ phân biệt unit test của core với contract và integration test của adapter.


Ports & Adapters: định vị boundary mà không cần microservices

Trong cách tổ chức Ports & Adapters, core công bố port cho những tương tác nó cần; adapter chuyển protocol hoặc công nghệ cụ thể sang port. DueEnrollmentQueryReminderSender là output ports của application. JpaDueEnrollmentAdapterSmsReminderAdapter kết nối chúng với database và external service. Composition Root lắp các phần mà không để core biết concrete type.

Dependency Rule có thể tóm tắt ở cấp source: dependency hướng vào policy ổn định hơn. Infrastructure được phép biết application contract để implement nó; application không được import infrastructure. Rule này có thể được kiểm tra bằng package convention, module dependency hoặc architecture test. Nó không yêu cầu runtime call chỉ được đi vào trong — runtime flow vẫn phải chạm I/O.

Một modular monolith hoàn toàn có thể áp dụng cấu trúc này trong một deployment unit. Không cần tách mỗi adapter thành microservice. Trên thực tế, solidbook khuyến nghị bắt đầu bằng module rõ ràng trước khi trả chi phí phân tán. DIP là về source dependency và khả năng bảo vệ policy, không phải topology triển khai. Chương 9 sẽ đi sâu hơn vào boundary kiến trúc; ở đây chỉ cần nắm một vertical slice hoàn chỉnh.

Boundary đầy đủ cũng không miễn phí. Clean Architecture chỉ ra rằng một ranh giới có interface, data structure hai phía, dependency management và chi phí bảo trì. Có lúc một Strategy interface trong cùng module là đủ; có lúc phải tách module để compiler ngăn import ngược. Mức bảo vệ phải theo rủi ro, không theo mong muốn làm sơ đồ cân đối.


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

Các dấu hiệu sau không tự động kết luận vi phạm, nhưng là điểm bắt đầu tốt để điều tra dependency direction:

  • Application service import org.springframework.data, JPA entity, HTTP client hoặc type của vendor SDK.

  • Test business rule cần @SpringBootTest, database container hoặc hàng loạt vendor DTO chỉ để đi qua một nhánh logic.

  • Đổi provider, schema hoặc protocol làm sửa class chứa policy dù yêu cầu nghiệp vụ không đổi.

  • Interface nằm cạnh adapter và lặp lại API hạ tầng; tên method dùng table, topic, bucket, SID hoặc response code mà use case không quan tâm.

  • Vendor exception hoặc persistence type đi xuyên từ infrastructure tới controller.

  • Business code gọi ApplicationContext.getBean(), static singleton hoặc global registry để tìm dependency.

  • Mỗi concrete class có một interface cùng tên nhưng không có boundary, variation hay test seam có giá trị.

  • application package import infrastructure, trong khi kiến trúc công bố core độc lập.

Khi gặp một dấu hiệu, hãy kiểm tra change history, ownership, test setup và type leakage. Một @Service import annotation Spring có thể là dependency ổn định được chấp nhận; một application service import query DSL của database có thể là coupling trọng yếu. Static analysis chỉ tìm mũi tên; đội phát triển vẫn phải đánh giá ý nghĩa và chi phí của nó.


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

“Có interface là đạt DIP.” Interface chỉ là cú pháp. Nếu contract do infrastructure định hình hoặc application vẫn import module low-level, hướng dependency chưa được đảo theo policy.

“Mọi class phải có cặp interface/implementation.” EnrollmentReferenceFormatterDefaultEnrollmentReferenceFormatter không đem lại giá trị nếu formatter ổn định, nội bộ và không có variation. Interface theo phản xạ làm tăng navigation cost và có thể che thiết kế thật sự cần bảo vệ.

“DI, IoC và DIP là cùng một khái niệm.” DI trả lời dependency được cung cấp thế nào; IoC trả lời ai nắm quyền điều khiển; DIP trả lời source dependency và abstraction hướng về policy nào. Chúng phối hợp nhưng không thay thế nhau.

“Spring tự động bảo đảm DIP.” Spring nối bean theo type mà developer thiết kế. Container có thể inject thẳng SmsVendorClient vào application service chính xác như yêu cầu; nó không biết đó là boundary sai.

“DIP cấm `new` và mọi concrete dependency.” Object phải được tạo ở đâu đó. Mục tiêu là gom construction của volatile concrete vào composition root và cho phép dùng trực tiếp những concrete ổn định khi hợp lý.

“High-level module là controller hoặc package trên cùng.” High-level được xác định bởi policy. Controller, database và framework đều có thể là outer detail quanh use case.

“Port phải có hai implementation production.” Một external provider duy nhất vẫn là volatile boundary; fake trong test hoặc adapter ở môi trường khác có thể là variation có giá trị. Ngược lại, hai implementation không cứu được một abstraction sai semantics.

“Ports & Adapters đồng nghĩa microservices.” Đây là cách tổ chức dependency. Nó hoạt động trong một module, modular monolith hoặc distributed system; deployment topology là quyết định khác.


Khi nào nên áp dụng, khi nào không nên ép — DIP theo rủi ro

DIP không nên được áp dụng cứng nhắc như một quota interface. Phần mang tính bối cảnh là dependency nào đáng bảo vệ, boundary sâu đến đâu và cơ chế kiểm chứng mạnh đến mức nào. Phần có thể kiểm tra là kiến trúc mà đội đã công bố: nếu application core được hứa là độc lập infrastructure, import ngược từ core sang infrastructure không còn là “sở thích”; đó là vi phạm rule của hệ thống.

Case nên áp dụng: reminder policy qua database và external provider

Case reminder có đủ bằng chứng để tạo port. Database và SMS provider đều có I/O, configuration, lỗi vận hành và change cadence riêng. JPA entity cùng vendor DTO đang rò vào policy. Unit test business rule bị buộc biết hạ tầng. Application có nhu cầu chạy bằng fake và có khả năng bổ sung email hoặc provider dự phòng. Boundary DueEnrollmentQuery/ReminderSender giảm một coupling đã quan sát, không chỉ chuẩn bị cho tương lai tưởng tượng.

Mức bảo vệ phù hợp hiện tại là port ở application package, adapter ở infrastructure, constructor injection, unit test core và integration test adapter. Vì toàn hệ thống vẫn là modular monolith do một team deploy, chưa cần tách service, repository hoặc versioned API. Nếu nhiều team cùng tích hợp port hoặc core được xuất bản thành library, trigger tiếp theo là tách module vật lý và thêm architecture/contract tests.

Case chưa nên áp dụng: formatter thuần và ổn định

Giả sử EnrollmentReferenceFormatter chỉ ghép course code, student code và ngày đăng ký thành một chuỗi hiển thị. Nó deterministic, không I/O, không phụ thuộc framework, chỉ có một client và thay đổi cùng use case. Tạo thêm EnrollmentReferenceFormatterInterface, DefaultEnrollmentReferenceFormatter, bean configuration và mock không bảo vệ một rủi ro đã thấy; nó chỉ thêm type phải đặt tên và điều hướng.

Dùng concrete trực tiếp trong trường hợp này là lựa chọn có chủ đích, không phải “bỏ qua SOLID”. Trigger xem xét lại xuất hiện nếu định dạng trở thành policy theo tenant, được sở hữu bởi team khác, cần nhiều strategy thực sự hoặc phụ thuộc external localization service. Khi bằng chứng đổi, ta có thể extract abstraction với hiểu biết tốt hơn về contract. YAGNI và cảnh báo wrong abstraction giúp tránh dựng boundary trước khi biết nó phải bảo vệ điều gì.

Câu hỏi

Nghiêng về tạo boundary/port

Nghiêng về dùng concrete trực tiếp

Biến động và rủi ro

External SDK, I/O, schema/provider thường đổi

Helper/value object ổn định

Ngôn ngữ

Vendor/ORM type rò vào policy

Type domain/JDK đã phù hợp

Test

Policy phải khởi động hạ tầng; fake có giá trị

Test thuần đã đơn giản

Ownership

Khác team, module hoặc release cadence

Cùng module và lý do thay đổi

Variation

Nhiều môi trường/adapter hoặc provider thứ hai gần

Chỉ có giả định tương lai mơ hồ

Chi phí

Port có tên và semantics tự nhiên

Interface gượng, mapping/wiring vượt lợi ích

Số implementation production không phải thước đo duy nhất. Một database adapter duy nhất vẫn đáng có port nếu core cần test độc lập và schema không được phép định hình policy. Hai implementation cũng không đủ nếu interface chỉ là mẫu số chung thấp nhất làm mất semantics. Hãy đánh giá rủi ro, ngôn ngữ, ownership và test cost cùng nhau.

Có thể dùng ba mức bảo vệ. Với helper thuần, ổn định và nội bộ, dùng concrete. Với I/O seam hoặc external dependency biến động, dùng application-owned port và adapter. Với public core hoặc boundary nhiều team, tách module vật lý, kiểm soát dependency bằng build và architecture tests. Mỗi mức thêm chi phí; chỉ nâng mức khi rủi ro hoặc bán kính thay đổi biện minh.

Ghi chú

Câu hỏi quyết định của DIP: dependency này có phải detail biến động đang định hình policy ổn định hơn không; nếu có, abstraction nên nói bằng ngôn ngữ của phía nào?

Từ ISP qua DIP đến testing và kiến trúc

ISP giúp EnrollmentReminderService chỉ nhìn thấy capability nó cần. DIP đặt capability ấy ở phía application và làm infrastructure phụ thuộc ngược vào contract. LSP yêu cầu mọi reminder adapter giữ cùng semantics; OCP cho phép thêm adapter phù hợp mà không sửa policy; SRP giữ mapping và delivery mechanism khỏi application rule. Năm chữ SOLID không tạo năm lớp code độc lập — chúng là năm góc nhìn lên cùng một dependency graph.

Chương 8 sẽ dùng boundary vừa tạo để phân chia test: unit test bảo vệ policy, contract test bảo vệ lời hứa của port và integration test xác nhận adapter làm việc với công nghệ thật. Chương 9 sẽ mở rộng từ một vertical slice sang boundary kiến trúc, package/module dependency và cách ngăn source dependency sai hướng quay trở lại.


Tóm tắt

  • DIP có hai mệnh đề: policy không phụ thuộc detail; abstraction không bị detail định hình, còn detail phụ thuộc abstraction.

  • Runtime call có thể đi ra database/provider trong khi source dependency của adapter hướng vào application port.

  • DI là cách cung cấp collaborator, IoC là sự đảo quyền điều khiển, DIP là hướng và mức trừu tượng của source dependency.

  • Spring là công cụ lắp object graph; Composition Root là nơi concrete dependency được cô lập. Framework không thiết kế port thay developer.

  • Interface chỉ có giá trị khi nói bằng ngôn ngữ của client và công bố semantics phù hợp; interface sao chép vendor API chưa tạo ra inversion có ý nghĩa.

  • Testability là hệ quả có thể quan sát, nhưng adapter vẫn cần contract/integration test và phải tuân LSP.

  • Áp dụng DIP mạnh ở volatile I/O boundary; dùng concrete trực tiếp cho helper ổn định khi abstraction không giảm rủi ro.

  • Linh hoạt nằm ở vị trí và độ sâu boundary, không nằm ở việc tuyên bố core độc lập rồi cho phép source dependency đi sai hướng.


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

Phần bài tập cuối chương kiểm tra hai mệnh đề DIP, phân biệt DIP, DI và IoC, phát hiện interface giả và Service Locator, refactor một boundary Java Spring Boot, đồng thời yêu cầu bạn bảo vệ quyết định không tạo abstraction khi chưa có bằng chứng.

Hỏi đáp

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