Soldemy
0%

SOLID trong Java Spring Boot

SOLID ở cấp kiến trúc và tổng kết

40 phút đọc

SOLID ở cấp kiến trúc và tổng kết

Cuối Chương 8, testing portfolio của EnrollmentReminderService đã phân chia khá rõ: plain unit tests bảo vệ policy, JPA tests bảo vệ query, adapter tests bảo vệ provider protocol và một số ít context tests bảo vệ wiring. Cấu trúc ấy hoạt động cho một use case. Tuy nhiên, codebase vẫn có thể suy giảm nếu một controller ở module khác import thẳng JPA adapter, nếu LearningService dùng EnrollmentEntity, hoặc nếu package shared trở thành đường tắt cho mọi dependency.

Một yêu cầu mới làm vấn đề hiện rõ: enrollment được gia hạn thủ công không được nhận reminder hết hạn. Thay đổi lẽ ra chỉ tác động policy và query boundary, nhưng pull request phải sửa controller, repository, scheduler, payment helper và một DTO đang được Learning module dùng chung. Khi developer cố chuyển rule vào LearningService, Enrollment bắt đầu phụ thuộc Learning; Learning vốn đã phụ thuộc Enrollment để đọc trạng thái thanh toán. Dependency graph xuất hiện cycle.

Không phần code riêng lẻ nào nhất thiết là God Object. Các class có thể ngắn, dùng constructor injection và có unit tests. Vấn đề nằm ở cấp cao hơn: boundary chưa rõ, public surface quá rộng và build không thực thi hướng dependency đã thống nhất trên sơ đồ. Chương cuối nâng cùng tư duy SOLID từ class/interface lên package, application module và model boundary.

Ghi chú

Kiến trúc không bắt đầu từ số layer hay số service. Nó bắt đầu từ những quyết định về boundary và dependency có ảnh hưởng lớn đến cost of change.

Architecture là quyết định khó thay đổi, không phải bản vẽ

Software architecture là tập những quyết định cấu trúc có ảnh hưởng đáng kể đến cách hệ thống được phát triển, kiểm thử, deploy và thay đổi. Mức độ đáng kể được đo bằng cost of change, không phải kích thước hộp trên diagram. Chọn framework, chia module, đặt ownership cho dữ liệu, công bố API và quyết định hướng source dependency đều có thể là quyết định kiến trúc nếu việc đảo ngược chúng tốn nhiều công sức hoặc rủi ro.

Design và architecture không phải hai hoạt động tách biệt tuyệt đối. Một method signature có thể chỉ là chi tiết local; một interface được hàng chục module sử dụng là compatibility boundary. Một package-private constructor có thể ngăn dependency ngoài ý muốn hiệu quả hơn một tài liệu dài. Kiến trúc hình thành từ code và được phản ánh qua code, dù team có vẽ diagram hay không.

Diagram chỉ là một view. Package tree cho biết code được tổ chức ra sao; source dependency graph cho biết module nào phải biết module nào; runtime trace cho biết control flow đi qua đâu; database ownership cho biết ai có quyền thay schema; team map cho biết thay đổi cần bao nhiêu nhóm phối hợp. Một diagram đẹp không bù được việc compiler cho phép controller truy cập internal repository của module khác.

Mục tiêu không phải giữ mọi option mở vô thời hạn. Mỗi option đều có chi phí: abstraction, mapping, tests, documentation và cognitive load. Architecture tốt giữ những option có giá trị kinh tế đủ lâu, đồng thời chấp nhận concrete decision ở nơi volatility thấp hoặc reversal cost nhỏ. Lời hứa “đổi database mà không sửa gì” thường quá mức; thay SQL bằng document database có thể kéo theo transaction và consistency semantics khác. Boundary chỉ khu trú phần thay đổi, không xóa công việc migration.

Trong code doanh nghiệp, behavior thường tạo áp lực khẩn cấp còn structure ít khi báo lỗi ngay. Team giao thêm feature bằng shortcut vẫn có thể đạt deadline hiện tại. Chi phí xuất hiện ở các lần sau: test phải bootstrap nhiều hơn, change lan rộng hơn, onboarding cần hiểu nhiều dependency ngầm hơn và incident khó cô lập hơn. Đây là lý do Chương 1 dùng cost of change làm thước đo, và cũng là điểm Chương 9 quay lại để khép vòng lập luận.


Bốn boundary thường bị trộn lẫn

Từ boundary được dùng cho nhiều loại phân chia. Nếu không chỉ rõ boundary đang bảo vệ điều gì, team dễ lấy một quyết định kỹ thuật thay cho một quyết định domain. Bảng sau phân biệt bốn loại thường gặp trong Spring Boot application.

Boundary

Cơ sở phân chia

Ví dụ LMS

Không tự động đồng nghĩa

Horizontal layer

Technical concern

web, application, domain, persistence

Business module

Vertical application module

Business capability và public API

enrollment, catalog, learning, billing

Bounded Context hoàn chỉnh

Bounded Context

Phạm vi model và language nhất quán

Enrollment khác Learning Progress

Package hoặc microservice duy nhất

Deployment boundary

Process, release và network

một service hoặc một modular monolith

Domain boundary đúng

Horizontal layer trả lời code làm loại công việc kỹ thuật nào. Controller chuyển HTTP, application service điều phối use case, repository adapter truy cập dữ liệu. Layer có giá trị vì nó tách concern, nhưng nếu toàn hệ thống chỉ có controller, servicerepository, một feature enrollment vẫn bị rải qua ba khu vực lớn.

Vertical application module trả lời code phục vụ capability nào và các module khác được phép gọi qua API nào. Một Enrollment module có thể chứa controller, application service, domain policy và persistence adapter của riêng nó. Cách chia này giúp change theo feature được khu trú tốt hơn, nhưng một module package chưa tự động có domain model nhất quán hay boundary được thực thi.

Bounded Context trả lời model nào có hiệu lực trong phạm vi nào. Cùng từ Course có thể chỉ offering được publish và định giá trong Catalog, nhưng trong Learning nó là nội dung có lesson, progress và completion rule. Hai model có thể cùng dùng courseId mà không chia sẻ một object model. Boundary ở đây bảo vệ meaning, không chỉ bảo vệ code visibility.

Deployment boundary đưa code sang process hoặc release unit khác. Quyết định này thêm network failure, serialization, observability, versioning và operational ownership. Tách microservice không tự sửa dependency sai; một distributed monolith vẫn có coupling chặt, chỉ thêm latency và partial failure.

Ghi chú

Một Bounded Context có thể là ứng viên cho deployment độc lập, nhưng không phải mệnh lệnh phải tạo microservice. Model boundary và deployment boundary là hai quyết định liên quan nhưng khác nhau.

Năm nguyên lý SOLID khi đơn vị thiết kế là module

SOLID được phát biểu ở cấp class và module, nhưng các lực thiết kế của nó không dừng ở một file Java. Khi đơn vị phân tích lớn hơn, mỗi nguyên lý biểu hiện qua public surface, hướng dependency và cách change lan giữa các nhóm code. Không nên chuyển đổi máy móc theo kiểu “mỗi chữ tương ứng một layer”; năm nguyên lý phối hợp để bảo vệ boundary.

Nguyên lý

Biểu hiện ở cấp module

Evidence cần quan sát

SRP

Gom code chịu cùng actor/axis of change; Common Closure ở cấp component

Change history tập trung hay lan qua nhiều module

OCP

Public API ổn định, extension qua strategy/adapter tại variation thật

Thêm implementation có phải sửa policy và clients cũ không

LSP

Mọi adapter giữ behavioral contract của port

Contract suite chạy qua các implementation

ISP

Module chỉ công bố capability từng consumer cần

Client có import API/internal type không dùng không

DIP

Source dependency hướng về policy và application-owned contract

Core có import framework/vendor/detail không

SRP gợi ý nơi đặt boundary thông qua reason to change. Ở cấp component, Common Closure Principle (CCP) khuyến nghị gom những class thay đổi cùng nguyên nhân và cùng thời điểm. Nếu Finance thường sửa payment reconciliation còn Learning Operations sửa completion rules, đặt cả hai trong một shared service làm mỗi release ảnh hưởng quá rộng.

Common Reuse Principle (CRP) tạo lực ngược lại: không ép consumer phụ thuộc vào phần nó không dùng. Một PlatformOperations module chứa enrollment, catalog, reports và notifications có thể gom change nhưng bắt mọi consumer nhận một release surface rất lớn. CCP nghiêng về developability và change locality; CRP nghiêng về consumer impact. Boundary phù hợp là điểm cân bằng theo giai đoạn, không phải kết quả cố định từ đầu dự án.

OCP thu lợi khi module có extension point đúng variation. LSP bảo đảm implementation mới không phá contract. ISP giữ API nhỏ để consumer không biết phần thừa. DIP xoay source dependency để detail thực hiện contract do policy định hình. Nếu thiếu một mắt xích, kiến trúc dễ chỉ còn hình thức: adapter thay thế được về type nhưng không thay thế được về behavior; hoặc module API ổn định nhưng vẫn trả vendor DTO.

Spring DI container giải quyết object construction và wiring. Nó không tự xác định interface thuộc module nào, không ngăn controller gọi repository ngoài boundary và không giữ model nhất quán. Constructor injection là cơ chế tốt, nhưng một object graph có thể được inject hoàn hảo trong khi source dependency vẫn sai hướng.


Package by layer: điểm khởi đầu hợp lý, giới hạn rõ ràng

Spring Boot không ép một layout duy nhất. Nhiều project bắt đầu bằng package theo technical layer vì cấu trúc dễ hiểu và phù hợp CRUD nhỏ. Layout này không phải anti-pattern tự thân.

Bash
com.soldemy
├── controller
│   ├── EnrollmentController.java
│   └── CourseController.java
├── service
│   ├── EnrollmentService.java
│   ├── ReminderService.java
│   └── CourseService.java
├── repository
│   ├── EnrollmentRepository.java
│   └── CourseRepository.java
└── client
    ├── PaymentClient.java
    └── SmsClient.java

Ưu điểm là convention quen thuộc: developer biết controller ở đâu, repository ở đâu; component scanning và tutorial Spring đều dễ theo. Với một admin tool gồm vài bảng và ít policy, layout này có thể duy trì đủ lâu. Chi phí xuất hiện khi số capability tăng: tất cả controller nằm cùng vùng, service package có hàng chục class và feature change phải đi qua nhiều top-level package.

Package by domain chuyển câu hỏi điều hướng từ “đây là service loại gì?” sang “đây là capability nào?”. Tài liệu Spring Boot hiện hành cũng minh họa layout top-level theo domain như customerorder, đồng thời trỏ sang Spring Modulith khi cần thực thi structure theo domain.

Bash
com.soldemy
├── SoldemyApplication.java
├── catalog
├── enrollment
├── learning
└── billing

Đổi folder chưa đủ. Nếu learning import enrollment.internal.JpaEnrollmentRepository, nếu mọi class đều public, hoặc nếu các module chia sẻ EnrollmentEntity, top-level package chỉ cải thiện navigation. Architecture cần public API, internal implementation và dependency rules có thể kiểm tra.


Bounded Context bảo vệ meaning của model

Bounded Context là phạm vi trong đó một domain model và Ubiquitous Language được giữ nhất quán. Bên trong boundary, cùng thuật ngữ phải có nghĩa tương thích và rules không mâu thuẫn. Bên ngoài boundary, model khác có thể dùng cùng tên theo nghĩa khác; integration cần translation rõ thay vì giả định hai bên đang nói về cùng object.

Trong Catalog, Course là offering có title, price, publish state, category và sales visibility. Enrollment không cần lesson tree; nó cần course reference, enrollment window, seat policy và quoted price. Learning dùng course content, progress và completion criteria. Một CourseEntity hợp nhất tất cả field sẽ bị thay đổi bởi Catalog, Finance và Learning, rồi được nhiều module truy cập theo những invariant khác nhau.

Sử dụng những model riêng không nhất thiết là duplication xấu. CatalogCourseIdLearningCourseId có thể bọc cùng UUID nhưng thuộc hai conversation khác nhau. Boundary translation xác nhận identity nào được chuyển, field nào có nghĩa và behavior nào không được mang sang. Việc cố tái sử dụng một entity vì cùng table hoặc cùng ID thường tiết kiệm mapping ngắn hạn nhưng tăng semantic coupling dài hạn.

Một failure mode khác là false cognate: hai khái niệm có cùng tên nhưng khác nghĩa. CompletedEnrollment trong Billing có thể nghĩa khoản thanh toán đã settled; trong Learning, completed có thể nghĩa learner đã đạt completion criteria. Nếu hai team dùng chung enum COMPLETED, report và reminder có thể diễn giải sai trạng thái mà compiler không phát hiện.

Bounded Context không được suy ra chỉ từ database schema. Evidence còn nằm ở language của stakeholder, team ownership, invariant, transaction need và change history. Một schema dùng chung có thể chứa nhiều model lịch sử; ngược lại, một context có thể lưu dữ liệu ở nhiều store. Context Map ghi các context và quan hệ của chúng để sự khác biệt được quản lý có chủ đích.


Tìm boundary bằng evidence

Danh từ nghiệp vụ là điểm bắt đầu, không phải kết luận. Enrollment, CoursePayment nghe như các module tự nhiên, nhưng boundary tốt cần evidence rằng code bên trong cohesive và code bên ngoài chỉ cần public conversation rõ. Team có thể xem bảy nhóm dữ liệu: actor, reason to change, invariant, transaction need, change-together history, consumer set và external volatility.

Git history cho biết những file nào thường đổi cùng nhau. Incident record cho biết failure lan qua đâu. Test fixture cho biết use case phải lắp bao nhiêu module. Pull request ownership cho biết thay đổi cần bao nhiêu team duyệt. Những dữ liệu này đáng tin hơn quy tắc “mỗi aggregate một module” áp trước khi hiểu domain.

Package common, shared hoặc util cần được xem xét vì nó thường thiếu owner và reason to change. Không phải mọi shared code đều sai: một immutable Money có semantics thực sự thống nhất có thể dùng chung. Nhưng nếu shared chứa JPA entities, request DTO, validation rules và helpers của nhiều capability, nó đang che các boundary chưa được quyết định.

Enrollment có đủ evidence để đầu tư: eligibility, time window, payment, persistence, reminder và notification chịu các failure mode khác nhau; nhiều actor thay đổi policy; provider và database là volatile details. CourseTagAdmin, ngược lại, hiện chỉ là CRUD nội bộ trên một table, một team và vài validation đơn giản. Hai phần không nên nhận cùng một liều lượng kiến trúc chỉ để repository trông đồng đều.


Hexagonal Architecture theo ý định Ports and Adapters

Hexagonal Architecture tổ chức application thành một phía inside chứa policy và một phía outside chứa các mechanism giao tiếp với người dùng, database, message broker hoặc external service. Application không biết loại thiết bị hay protocol đang drive nó. Adapter chuyển đổi protocol bên ngoài thành conversation mà application hiểu, hoặc thực hiện conversation mà application yêu cầu.

Port không đơn thuần là một interface đặt trước adapter. Port là một conversation có mục đích tại application boundary. EnrollStudent diễn đạt khả năng application cung cấp; PaymentAuthorizer diễn đạt capability application cần. Nếu tạo StripeClientPort với toàn bộ method và DTO của Stripe SDK, tên có interface nhưng ownership vẫn thuộc vendor detail.

Inbound port, còn gọi là driving port, được REST controller, scheduler, CLI, batch job hoặc test harness gọi để kích hoạt use case. Outbound port, còn gọi là driven port, được application gọi để lưu enrollment, hỏi eligibility, authorize payment hoặc gửi reminder. Driving adapter chuyển input ngoài thành command; driven adapter chuyển request của port thành database query hoặc provider request.

Một port có thể có nhiều adapters. PaymentAuthorizer có adapter cho provider production, sandbox và Fake dùng trong test. Một adapter cũng có thể implement nhiều role interfaces nếu các role cohesive và cùng release reason; ISP không yêu cầu một class chỉ implement một interface.

Hình lục giác không quy định sáu cạnh, sáu port hay sáu layer. Cockburn dùng hình này để tránh tư duy một chiều trên–dưới của layered diagram và dành chỗ cho nhiều port. Điều quan trọng là inside/outside asymmetry: outer detail biết application contract; application không biết concrete outer detail.


Runtime flow đi ra ngoài, source dependency vẫn hướng vào

Một HTTP request chạy theo chuỗi: controller gọi EnrollStudent, application policy hỏi CourseEligibility, gọi PaymentAuthorizer, rồi lưu qua EnrollmentStore. Runtime control flow đi từ adapter vào core, sau đó từ core ra adapters khác. Dependency Rule không cấm control flow đi ra ngoài; nó kiểm soát source code biết type của phía nào.

Application định nghĩa PaymentAuthorizer. Payment adapter import và implement interface đó, đồng thời biết vendor SDK. Application service chỉ import port và application types. Dynamic polymorphism cho phép runtime call đi tới concrete adapter dù source dependency của adapter hướng vào application.

JavaScript
// enrollment/internal/port/PaymentAuthorizer.java
public interface PaymentAuthorizer {
    PaymentAuthorization authorize(PaymentRequest request);
}

public record PaymentRequest(
        UUID studentId,
        UUID courseId,
        Money amount) {
}

public sealed interface PaymentAuthorization {
    record Approved(String reference) implements PaymentAuthorization {}
    record Rejected(String reason) implements PaymentAuthorization {}
}

// enrollment/internal/adapter/payment/AcmePaymentAdapter.java
@Component
final class AcmePaymentAdapter implements PaymentAuthorizer {

    private final AcmePaymentClient client;

    AcmePaymentAdapter(AcmePaymentClient client) {
        this.client = client;
    }

    @Override
    public PaymentAuthorization authorize(PaymentRequest request) {
        AcmeChargeResponse response = client.charge(
                request.studentId().toString(),
                request.amount().value(),
                request.amount().currency().getCurrencyCode());

        return response.approved()
                ? new PaymentAuthorization.Approved(response.reference())
                : new PaymentAuthorization.Rejected(response.reasonCode());
    }
}

Data crossing boundary cũng phải tôn trọng Dependency Rule. Application không nên nhận ResponseEntity, JPA entity, Spring Data Page hoặc vendor response nếu những type đó buộc core biết outer framework. Command, result và Value Object nên nói bằng application semantics. Adapter chịu trách nhiệm mapping tại nơi hai model/protocol gặp nhau.

Điều này không có nghĩa tạo DTO mới ở mọi method call. Nếu hai class ở cùng model boundary dùng chung immutable Value Object, copy nó chỉ tăng boilerplate. Mapping có giá trị khi nó cắt một technology dependency hoặc semantic difference; mapping không có lý do chỉ tạo một chuỗi object giống hệt nhau.


Before capstone — constructor injection đúng nhưng boundary vẫn sai

Code dưới đây đại diện cho một kiểu phát triển phổ biến. Controller cần trả lỗi duplicate sớm nên gọi repository; service dùng payment SDK trực tiếp vì client đã có; JPA entity được dùng như domain object để giảm mapping. Mọi dependency đều được constructor-inject, nhưng application policy và adapters chưa có boundary rõ.

JavaScript
@RestController
@RequestMapping("/api/enrollments")
public class EnrollmentController {

    private final EnrollmentService service;
    private final SpringDataEnrollmentRepository repository;

    public EnrollmentController(
            EnrollmentService service,
            SpringDataEnrollmentRepository repository) {
        this.service = service;
        this.repository = repository;
    }

    @PostMapping
    ResponseEntity<?> enroll(@RequestBody EnrollmentRequest request) {
        if (repository.existsActive(request.studentId(), request.courseId())) {
            return ResponseEntity.status(409).body("ACTIVE_ENROLLMENT_EXISTS");
        }
        return ResponseEntity.ok(service.enroll(request));
    }
}

@Service
public class EnrollmentService {

    private final SpringDataEnrollmentRepository repository;
    private final AcmePaymentClient paymentClient;
    private final SmsVendorClient smsClient;

    public EnrollmentService(
            SpringDataEnrollmentRepository repository,
            AcmePaymentClient paymentClient,
            SmsVendorClient smsClient) {
        this.repository = repository;
        this.paymentClient = paymentClient;
        this.smsClient = smsClient;
    }

    @Transactional
    public EnrollmentEntity enroll(EnrollmentRequest request) {
        AcmeChargeResponse charge = paymentClient.charge(request.toCharge());
        if (!charge.approved()) {
            throw new PaymentRejectedException(charge.reasonCode());
        }

        EnrollmentEntity saved = repository.save(
                EnrollmentEntity.active(request, charge.reference()));
        smsClient.sendTemplate("enrollment-active", saved.getStudentPhone());
        return saved;
    }
}

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

`EnrollmentController` bypass application policy để gọi persistence. `EnrollmentService` import vendor request/response và trả JPA entity. Đổi payment SDK sửa service và tests; đổi schema ảnh hưởng API response cùng Learning consumer; thêm enrollment channel mới dễ sao chép duplicate check vì public use case boundary chưa tồn tại.

Phân tích theo SOLID cho thấy nhiều lực cùng tác động. SRP/CCP bị suy giảm vì policy, provider mapping và notification change cùng một class/package. OCP yếu vì thay provider sửa core. LSP chưa có application contract để các provider adapters cùng giữ. ISP yếu vì repository/entity surface bị công bố rộng. DIP sai hướng vì application import concrete framework/vendor details.

Không nên sửa bằng một lần viết lại toàn bộ. Chương 8 đã cung cấp safety net: behavior tests cho duplicate, eligibility và payment outcome; adapter integration tests cho JPA/provider; contract tests cho ports. Refactor structure theo bước nhỏ giữ các tests ấy xanh và giúp failure được định vị.


Refactor boundary theo lát nhỏ

Bước đầu tiên là xác định observable use case và public module API, không phải tạo folder. Với Enrollment, API cần nhận command enrollment và trả result có nghĩa đối với caller. Duplicate check phải nằm sau cùng entry point để REST, batch hoặc admin flow không tự triển khai policy khác nhau.

Tiếp theo, code được gom theo capability. Domain policy và application orchestration chuyển vào Enrollment module. Framework types được giữ ở adapters. Outbound ports chỉ được extract tại nơi có external volatility hoặc semantic boundary đã quan sát: persistence, catalog eligibility, payment authorization và reminder delivery.

Sau đó adapters implement ports, composition root lắp object graph và architecture rules ngăn import sai hướng. Trong quá trình chuyển, bridge adapter hoặc deprecated API có thể tồn tại ngắn hạn để tránh big-bang migration. Khi callers đã chuyển sang module API, bridge được xóa và public surface được thu hẹp.

Điểm dừng quan trọng không kém điểm bắt đầu. Khi use case boundary rõ, policy test độc lập, adapters được kiểm chứng và dependency rule được thực thi, việc tiếp tục chia mỗi helper thành port không mua thêm confidence. Architecture không cần đối xứng; nó cần tương xứng với risk.


After capstone — public API nhỏ, core nói bằng ngôn ngữ application

Bash
com.soldemy
├── SoldemyApplication.java
├── enrollment
│   ├── EnrollmentManagement.java
│   ├── EnrollmentCommand.java
│   ├── EnrollmentResult.java
│   └── internal
│       ├── application
│       │   └── EnrollmentApplicationService.java
│       ├── domain
│       │   ├── Enrollment.java
│       │   └── EnrollmentPolicy.java
│       ├── port
│       │   ├── EnrollmentStore.java
│       │   ├── CourseEligibility.java
│       │   └── PaymentAuthorizer.java
│       └── adapter
│           ├── web
│           ├── persistence
│           └── payment
├── catalog
├── learning
└── billing

Package gốc enrollment công bố những type module khác được phép dùng. Phần internal có thể chứa public Java types vì các subpackage cần gọi nhau, nhưng chúng không thuộc module API. Java package visibility và architecture verification sẽ phối hợp để giữ giới hạn này.

JavaScript
// com.soldemy.enrollment.EnrollmentManagement
public interface EnrollmentManagement {
    EnrollmentResult enroll(EnrollmentCommand command);
}

public record EnrollmentCommand(UUID studentId, UUID courseId) {
    public EnrollmentCommand {
        Objects.requireNonNull(studentId);
        Objects.requireNonNull(courseId);
    }
}

public sealed interface EnrollmentResult {
    record Activated(UUID enrollmentId) implements EnrollmentResult {}
    record Rejected(String reason) implements EnrollmentResult {}
}

API không trả JPA entity hoặc payment response. Caller biết enrollment được activate hay rejected; nó không biết record nằm ở table nào hoặc provider dùng reason code nào. Nếu một consumer chỉ cần query status, có thể công bố một role API riêng thay vì làm EnrollmentManagement trở thành facade chứa mọi operation.

JavaScript
public final class EnrollmentApplicationService implements EnrollmentManagement {

    private final EnrollmentStore store;
    private final CourseEligibility eligibility;
    private final PaymentAuthorizer paymentAuthorizer;
    private final Clock clock;

    public EnrollmentApplicationService(
            EnrollmentStore store,
            CourseEligibility eligibility,
            PaymentAuthorizer paymentAuthorizer,
            Clock clock) {
        this.store = store;
        this.eligibility = eligibility;
        this.paymentAuthorizer = paymentAuthorizer;
        this.clock = clock;
    }

    @Override
    public EnrollmentResult enroll(EnrollmentCommand command) {
        if (store.existsActive(command.studentId(), command.courseId())) {
            return new EnrollmentResult.Rejected("ACTIVE_ENROLLMENT_EXISTS");
        }

        EligibleCourse course = eligibility.forStudent(
                command.courseId(), command.studentId());
        if (!course.eligible()) {
            return new EnrollmentResult.Rejected(course.reason());
        }

        PaymentAuthorization authorization = paymentAuthorizer.authorize(
                new PaymentRequest(command.studentId(), command.courseId(), course.price()));
        if (authorization instanceof PaymentAuthorization.Rejected rejected) {
            return new EnrollmentResult.Rejected(rejected.reason());
        }

        var approved = (PaymentAuthorization.Approved) authorization;
        Enrollment enrollment = Enrollment.activate(
                UUID.randomUUID(), command.studentId(), command.courseId(),
                approved.reference(), clock.instant());

        store.save(enrollment);
        return new EnrollmentResult.Activated(enrollment.id());
    }
}

Application service điều phối policy; nó không biết HTTP, SQL hoặc SDK. Clock làm time dependency explicit. Domain object bảo vệ invariant activation; ports diễn đạt capability application cần. Đây không phải sự tách tuyệt đối giữa application và domain trong mọi dự án. Với use case đơn giản, hai phần có thể cùng package; dependency direction và model semantics quan trọng hơn số folder.

Ví dụ tập trung vào dependency structure nên chưa triển khai toàn bộ payment failure protocol. Trong production, authorization cần idempotency key; nếu persistence thất bại sau khi provider đã giữ tiền, application phải có retry hoặc compensation policy rõ. Database transaction không thể làm external provider call trở thành atomic. Hexagonal Architecture giúp đặt failure handling đúng boundary, nhưng không tự giải quyết distributed consistency.


Adapters chuyển protocol, Composition Root lắp object graph

JavaScript
@RestController
@RequestMapping("/api/enrollments")
final class EnrollmentHttpAdapter {

    private final EnrollmentManagement enrollmentManagement;

    EnrollmentHttpAdapter(EnrollmentManagement enrollmentManagement) {
        this.enrollmentManagement = enrollmentManagement;
    }

    @PostMapping
    ResponseEntity<EnrollmentResponse> enroll(
            @Valid @RequestBody EnrollmentRequest request) {
        EnrollmentResult result = enrollmentManagement.enroll(
                new EnrollmentCommand(request.studentId(), request.courseId()));

        if (result instanceof EnrollmentResult.Activated activated) {
            return ResponseEntity.ok(
                    new EnrollmentResponse(activated.enrollmentId(), "ACTIVE"));
        }

        var rejected = (EnrollmentResult.Rejected) result;
        HttpStatus status = "ACTIVE_ENROLLMENT_EXISTS".equals(rejected.reason())
                ? HttpStatus.CONFLICT
                : HttpStatus.UNPROCESSABLE_ENTITY;
        return ResponseEntity.status(status)
                .body(new EnrollmentResponse(null, rejected.reason()));
    }
}

Controller chỉ chuyển HTTP request/response. Duplicate policy không còn bị sao chép ở delivery layer. Một CLI hoặc admin batch gọi cùng EnrollmentManagement sẽ nhận cùng behavior. HTTP status mapping vẫn thuộc adapter vì nó là delivery protocol decision.

JavaScript
@Repository
final class JpaEnrollmentAdapter implements EnrollmentStore {

    private final SpringDataEnrollmentRepository repository;

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

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

    @Override
    public void save(Enrollment enrollment) {
        repository.save(EnrollmentJpaEntity.fromDomain(enrollment));
    }
}

JPA adapter sở hữu mapping giữa persistence representation và application/domain model. Unit tests của core không mock JpaRepository; adapter được kiểm chứng bằng @DataJpaTest hoặc Testcontainers khi database semantics quan trọng. Tách model tạo thêm mapping, nhưng đổi lại schema và query không trở thành public contract của Enrollment module.

JavaScript
@Configuration
class EnrollmentConfiguration {

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

    @Bean
    EnrollmentManagement enrollmentManagement(
            EnrollmentStore store,
            CourseEligibility eligibility,
            PaymentAuthorizer paymentAuthorizer,
            Clock clock) {
        return new EnrollmentApplicationService(
                store, eligibility, paymentAuthorizer, clock);
    }
}

Composition Root biết concrete beans và lắp chúng thành object graph. Concrete dependency không biến mất; nó được cô lập. Application core có thể dùng @Service nếu team chấp nhận stable Spring annotation dependency, nhưng capstone dùng plain Java để làm Dependency Rule dễ quan sát và giữ unit tests không cần context. Annotation không tự động làm code kém; volatility, portability và test cost mới là evidence quyết định.

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

Đổi payment provider sửa payment adapter, configuration và adapter tests. Đổi REST representation sửa web adapter. Đổi enrollment eligibility rule sửa policy và unit scenarios. Đổi Catalog semantics có thể yêu cầu cập nhật translation tại `CourseEligibility` boundary; core chỉ đổi nếu business meaning của enrollment thật sự đổi.

Giao tiếp giữa module: direct API, outbound port hay event

Không có một cơ chế giao tiếp đúng cho mọi quan hệ. Lựa chọn phải dựa trên consistency và failure semantics. Direct module API phù hợp khi caller cần result ngay. Outbound port phù hợp khi application cần capability nhưng không nên biết detail hoặc model bên cung cấp. Event phù hợp khi reaction độc lập, có thể xảy ra sau transaction và failure không được phép đảo ngược operation gốc một cách ngầm định.

Enrollment cần hỏi Catalog eligibility trước khi activate; đây là synchronous decision. Payment authorization cũng phải trả result vì rejected payment làm use case thất bại. Notification sau activation có thể xử lý từ EnrollmentActivated nếu requirement chấp nhận delayed delivery và có retry/idempotency policy. Analytics càng phù hợp với event vì nó không nên làm enrollment thất bại.

Event không tạo loose coupling miễn phí. Publisher và consumer vẫn coupling qua event semantics, version và timing. Event thêm ordering, duplicate delivery, observability và eventual consistency. Nếu caller cần biết ngay learner access đã được cấp, đổi direct call thành event chỉ để tránh import có thể che một transaction requirement chưa được giải quyết.

Một nguyên tắc vận hành hữu ích là chọn mechanism đơn giản nhất giữ đúng business semantics. Direct call trong modular monolith không phải thiết kế kém nếu module API rõ. Khi evidence về independent reaction hoặc team/release autonomy xuất hiện, event hoặc deployment boundary mới có cơ sở.


Boundary phải được compiler hoặc build thực thi

Quy ước trong tài liệu và code review có giá trị, nhưng dễ suy giảm dưới deadline. Boundary nên được thực thi ở mức thấp nhất có thể. Thứ tự hợp lý là access modifier, package/module structure, architecture tests, rồi documentation và review như lớp bổ sung.

Java package-private ngăn type bị dùng ngoài cùng package. Tuy nhiên, Java subpackage không thừa hưởng visibility của parent. Một public class trong enrollment.internal.persistence vẫn có thể bị learning import. Khi module có nhiều subpackage, ArchUnit hoặc Spring Modulith có thể kiểm tra rule mà compiler package visibility không diễn đạt được.

JavaScript
@AnalyzeClasses(packages = "com.soldemy")
class ArchitectureTest {

    @ArchTest
    static final ArchRule enrollment_domain_is_framework_independent =
            noClasses()
                    .that().resideInAPackage("..enrollment.internal.domain..")
                    .should().dependOnClassesThat().resideInAnyPackage(
                            "org.springframework..",
                            "jakarta.persistence..",
                            "com.acme.payment..");

    @ArchTest
    static final ArchRule business_modules_are_acyclic =
            slices()
                    .matching("com.soldemy.(*)..")
                    .should().beFreeOfCycles();
}

ArchUnit phân tích Java bytecode và cho phép viết rules về class/package dependencies, layers, slices và cycles bằng test Java. Rule trên bảo vệ hai quyết định có nghĩa: domain không biết framework/vendor và top-level business modules không có cycle. Nó không kiểm tra enrollment eligibility đúng hay payment mapping chính xác; behavior và adapter tests vẫn cần thiết.

Không nên biến architecture tests thành danh sách hàng chục naming rules. Rule “mọi service phải kết thúc bằng Service” có thể khóa vocabulary mà không bảo vệ business boundary. Rule tốt mô tả điều team thật sự không muốn bị phá và failure message giúp developer biết cách sửa.


Spring Modulith: toolkit cho logical modules, không phải Hexagonal Architecture

Spring Modulith là toolkit có opinion cho việc xây domain-driven modular Spring Boot application. Nó mô hình hóa application module theo package, phân biệt provided interface với internal implementation, kiểm tra cycle và illegal internal access, hỗ trợ module integration tests cùng documentation. Phiên bản tài liệu hiện hành khi chương được nghiên cứu là 2.1.0; dự án thực tế phải chọn release tương thích Spring Boot qua BOM/compatibility matrix.

Spring Modulith không phải điều kiện để áp Hexagonal Architecture. Hexagonal là architecture pattern; ArchUnit là thư viện static architecture testing; Spring Modulith là toolkit cho application modules trong hệ sinh thái Spring. Một dự án dùng Maven modules và ArchUnit tốt có thể không cần Modulith. Một dự án dùng Modulith vẫn có thể đặt sai Bounded Context hoặc thiết kế port kém.

JavaScript
class ModularityTest {

    @Test
    void module_boundaries_are_valid() {
        ApplicationModules.of(SoldemyApplication.class).verify();
    }
}

verify() kiểm tra module graph không cycle, module khác không truy cập internal packages và—khi khai báo—chỉ dùng allowed dependencies/named interfaces. Một test ngắn biến architecture convention thành build constraint. Team có thể bắt đầu từ default package convention rồi thêm annotation chỉ khi structure cần diễn đạt chi tiết hơn.

JavaScript
package com.soldemy.enrollment;

@ApplicationModuleTest
class EnrollmentModuleIntegrationTest {

    @Autowired
    EnrollmentManagement enrollmentManagement;

    @Test
    void activates_an_eligible_enrollment() {
        EnrollmentResult result = enrollmentManagement.enroll(
                new EnrollmentCommand(STUDENT_ID, COURSE_ID));

        assertThat(result).isInstanceOf(EnrollmentResult.Activated.class);
    }
}

@ApplicationModuleTest bootstrap module đang kiểm tra và các dependency được chọn theo mode, thay vì mặc định nạp toàn application. Test này bảo vệ Spring wiring và public interaction của Enrollment; plain unit tests vẫn là nơi kiểm tra nhiều business scenarios. Công cụ chỉ đáng dùng khi nó giảm feedback cost hoặc thực thi rule team cần.


Dependency cycle và shared module làm boundary suy giảm

Acyclic Dependencies Principle (ADP) yêu cầu module dependency graph không có cycle. Nếu Enrollment phụ thuộc Learning để activate access còn Learning phụ thuộc Enrollment để đọc payment status, hai module không thể hiểu, test hoặc thay đổi độc lập như sơ đồ tuyên bố. Build order và blast radius trở nên khó dự đoán.

Cách phá cycle phụ thuộc semantics. Nếu Learning chỉ cần một capability, nó có thể phụ thuộc public Enrollment query API. Nếu Enrollment cần gọi Learning nhưng contract thuộc nhu cầu Enrollment, DIP có thể đặt port phía Enrollment và để adapter thực hiện. Nếu activation reaction độc lập, event có thể phù hợp. Nếu hai phần luôn thay đổi, cùng transaction và cùng team, cycle có thể là dấu hiệu boundary ban đầu sai; gộp lại có thể trung thực hơn tạo thêm interface.

Một cách sửa thiếu hiệu quả là chuyển type chung sang shared cho đến khi cycle biến mất trên diagram. Dependency vẫn tồn tại và shared module nhận mọi reason to change. Chỉ tách shared component khi semantics thực sự thống nhất, consumer set rõ và stability đủ cao. Money có thể là shared kernel nhỏ; EnrollmentEntity với status của ba context thường không phải.

Stable dependency không nghĩa module không bao giờ đổi. Một module có nhiều dependents khó đổi vì compatibility cost cao; do đó API của nó cần nhỏ, coherent và có contract tests. Module volatile nên nằm ở outer edge, nơi ít consumer phụ thuộc trực tiếp. Đây là DIP ở cấp component, không phải lời kêu gọi tạo abstraction cho mọi code.


Modular monolith trước, microservices khi có evidence

Modular monolith là một deployable unit có internal modules và dependency boundaries rõ. Nó giữ transaction, local calls, debugging và deployment đơn giản hơn distributed system, đồng thời cho phép codebase phản ánh domain capability. Capstone chọn cấu trúc này vì scope chưa có yêu cầu independent scaling hoặc release ownership đủ mạnh để trả network/operations cost.

Một modular monolith có thể chứa nhiều application modules và, tùy model/team, nhiều Bounded Context. Không nên áp công thức một context bằng một repository hoặc một service. Điều quan trọng là model boundary được nhận diện, translation được explicit và module interaction không bypass contract.

Deployment split đáng xem xét khi có team autonomy thật, release cadence khác, scaling profile riêng, regulatory isolation hoặc failure containment cần process boundary. Trước khi tách, data ownership, public contract, observability và operational responsibility phải đủ trưởng thành. Nếu code trong monolith còn cycle và dùng chung entity, chuyển sang HTTP chỉ tạo distributed coupling.

Microservices không phải cấp độ cao hơn của SOLID. SOLID giúp reasoning về responsibility, extension, contract, interface và dependency ở bất kỳ deploy topology nào. Một well-structured monolith có thể có cost of change thấp hơn một hệ microservices chia sai boundary.


Testing portfolio ở cấp module

Architecture boundary cần hai loại evidence. Behavior tests chứng minh hệ thống làm đúng; architecture tests chứng minh source structure tuân rules đã chọn. Một bên không thay bên kia. ApplicationModules.verify() pass không chứng minh payment rejected được xử lý đúng; unit suite xanh không ngăn Learning import internal JPA adapter trong pull request sau.

Test type

Risk chính được phát hiện

Không chứng minh

Plain unit test

Domain/application policy

JPA, provider, wiring

Contract test

Port implementations giữ observable contract

Vendor production availability

Adapter integration test

Mapping, query, protocol assumption

Toàn flow được wire đúng

Module integration test

Public API và module wiring

Mọi edge case policy

Architecture test

Dependency direction, internal access, cycle

Business behavior đúng

Capstone giữ scenarios ở boundary nhỏ nhất tạo confidence. Duplicate enrollment, eligibility và payment outcome chạy bằng plain Java. JPA adapter có test database phù hợp. Payment adapter dùng stub server/sandbox theo protocol risk. Module test xác nhận Spring wiring. Architecture tests chạy nhanh trên mỗi build để ngăn dependency regression.

Nếu Enrollment module không thể bootstrap mà phải kéo toàn Catalog, Learning, Billing và reporting infrastructure, đây là signal dependency graph cần xem lại. Tuy nhiên, signal không tự động dẫn tới microservice. Có thể public API quá rộng, test configuration chưa đúng hoặc consistency requirement thực sự buộc các module phối hợp chặt.


Những anti-patterns khi áp dụng SOLID ở cấp kiến trúc

Interface và adapter cho mọi class. Pure formatter, immutable Value Object hoặc local algorithm không cần port chỉ vì nằm trong Hexagonal project. Port đại diện application conversation hoặc volatility boundary, không phải wrapper cho từng method.

Folder đúng tên, dependency sai hướng. Tạo domain, application, infrastructure rồi để domain import JPA annotation/vendor DTO chỉ cải thiện hình thức. Architecture được quyết định bởi dependencies và contracts, không bởi tên folder.

Mapping ở mọi tầng. Một request đi qua năm object giống hệt nhau tạo navigation và maintenance cost. Mapping đáng giá khi cắt framework type hoặc model semantics; trong cùng boundary, shared immutable type có thể rõ hơn.

Coi mọi Spring annotation là contamination. @Transactional hoặc @Service có trade-off thật, nhưng không phải mọi dependency vào Spring đều volatile hoặc gây test cost đáng kể. Tách framework hoàn toàn chỉ hợp lý khi portability, policy isolation hoặc boundary clarity mua được nhiều hơn boilerplate.

Public hóa mọi type. Khi mọi implementation là public, package chỉ còn chức năng sắp xếp. Consumer có thể bypass service để dùng repository, rồi shortcut trở thành de facto contract. Access modifier và module verification nên thu hẹp surface.

Một canonical model cho toàn doanh nghiệp. Cùng entity được dùng giữa Catalog, Enrollment, Learning và Billing tạo field/invariant mâu thuẫn. Integration qua ID/snapshot/contract có chủ đích thường an toàn hơn chia sẻ object graph.

Event cho mọi giao tiếp. Event giảm compile-time dependency nhưng thêm temporal coupling, eventual consistency và operations cost. Direct module API có thể là lựa chọn tốt hơn khi caller cần result trong cùng transaction.

Tách Maven module hoặc microservice quá sớm. Source trees, builds và deployments độc lập có cost. Bắt đầu bằng package/module boundary có thể kiểm chứng, rồi nâng decoupling mode khi team/release/risk evidence xuất hiện.

Architecture tests kiểm tra implementation detail. Rule về suffix, annotation hoặc folder symmetry có thể làm refactor khó mà không bảo vệ capability. Mỗi rule cần giải thích dependency/business risk cụ thể mà nó ngăn.

Ghi chú

Boundary đáng giá khi nó cô lập một model, policy hoặc volatility có hậu quả thay đổi quan sát được. Số interface, package và module không phải quality metric.

Khi nào nên đầu tư sâu — và khi nào chưa nên ép

SOLID ở cấp kiến trúc có chi phí lớn hơn class-level refactor: package indirection, mappings, architecture tests, module configuration, documentation và onboarding. Vì thế phần linh hoạt nằm ở việc chọn boundary, mức enforcement và thời điểm đầu tư. Nó không nằm ở việc công bố contract rồi cho phép internal dependency bypass contract khi deadline đến.

Case nên đầu tư sâu: Enrollment

Enrollment có nhiều policy và failure boundaries: eligibility, duplicate, price/payment, activation time, persistence, reminder và notification. Finance, learner operations, Catalog và compliance tạo các reason to change khác nhau. Provider/schema changes từng buộc sửa policy; tests đã cho thấy core có thể chạy độc lập. Evidence này biện minh cho package by domain, application-owned ports, adapter isolation, module API và architecture verification.

Mức bảo vệ tương xứng là Hexagonal bên trong Enrollment module, ArchUnit hoặc Spring Modulith để ngăn import sai, contract/integration tests cho adapters và module test cho wiring. Không cần deploy riêng vì chưa có independent scaling/release/team requirement. Đây là thiết kế có chủ đích nhưng vẫn giữ một modular monolith.

Case chưa nên ép: CourseTagAdmin

CourseTagAdmin hiện có hai endpoints, một table, một repository và validation tên tag duy nhất. Một team sở hữu toàn flow; change history thấp; không có provider, alternative adapter hoặc model conflict. Package by feature với controller, service và repository rõ, kèm web/JPA tests, tạo đủ confidence.

Tạo CreateCourseTagUseCase, CourseTagStorePort, JpaCourseTagAdapter, bốn DTO mappings và Maven module riêng sẽ tăng code/navigation mà chưa giảm blast radius. Quyết định này không đóng vĩnh viễn. Team xem xét lại nếu xuất hiện approval workflow, localized taxonomy, external synchronization, nhiều actor hoặc regressions lặp lại ở persistence/integration boundary.

Evidence hoặc risk

Mức đầu tư ban đầu

Trigger nâng mức

CRUD ổn định, một team

Package by feature/layer rõ

Rule, actor hoặc integration tăng

Policy testable và volatile I/O

Ports/adapters trong feature

Public consumers/module boundary tăng

Nhiều capability cùng deploy

Modular monolith với enforced modules

Team/release/scale isolation thật

Model semantics xung đột

Bounded Context và translation

Integration contract phức tạp hơn

Independent operational need

Cân nhắc deployment split

Boundary và data ownership đã trưởng thành

Phần correctness không thay đổi theo liều lượng. Nếu module công bố public API, clients không nên bypass internal types. Nếu adapter implement port, nó phải giữ behavioral contract. Nếu hai Bounded Context dùng model khác, translation không được giả vờ rằng shared entity có cùng nghĩa. Architecture có thể đơn giản; contract đã công bố phải trung thực.

Ghi chú

Câu hỏi định hướng: boundary này đang bảo vệ model, policy hoặc volatility nào; bằng chứng nào cho thấy chi phí bảo vệ thấp hơn blast radius hiện tại?

Đọc capstone bằng evidence của từng chữ SOLID

SRP không được chứng minh bằng việc mỗi class ngắn. Evidence là thay đổi payment provider nằm ở adapter, eligibility rule nằm ở policy và HTTP representation nằm ở controller. CCP củng cố điều này ở module level: những thứ đổi cùng nhau được đặt gần nhau.

OCP không được chứng minh bằng số interface. Evidence là thêm payment adapter hoặc delivery channel tại variation boundary không sửa enrollment policy, miễn application semantics không đổi. Nếu business rule đổi, core phải sửa; OCP không đóng code trước mọi requirement.

LSP xuất hiện qua contract suite: sandbox, production adapter và Fake cùng trả Approved/Rejected theo cùng observable semantics. ISP xuất hiện ở EnrollmentManagement nhỏ và role-based query API, thay vì một module facade chứa mọi operation. DIP xuất hiện qua source dependencies từ adapters vào application-owned ports.

Tests cung cấp evidence behavior; architecture tests cung cấp evidence structure. Package tree giúp người mới tìm capability; Context Map giúp team không trộn model; modular monolith giữ operational cost phù hợp. Không artifact nào riêng lẻ chứng minh architecture tốt. Chúng phối hợp để giữ cost of change trong phạm vi chấp nhận được.


Checklist review kiến trúc Spring Boot

  • Top-level packages nói về domain capability hay chỉ technical layer?

  • Public API của mỗi module là gì; module khác có import internal type không?

  • Application/domain code đang import framework hoặc vendor type nào, và dependency đó có volatility thật không?

  • Module dependency graph có cycle hay shortcut bypass policy không?

  • Cùng thuật ngữ có semantics khác giữa contexts không?

  • Data crossing boundary nói bằng model của bên nào; translation nằm ở đâu?

  • Direct call, port hoặc event có phù hợp consistency và failure semantics không?

  • Compiler, build hoặc architecture test đang thực thi rule nào?

  • Testing portfolio chứng minh behavior, adapter assumption và wiring ở boundary nào?

  • Boundary nào tồn tại chỉ vì template, chưa có change/risk evidence?

Checklist là công cụ cho conversation và review, không phải bảng điểm. Một câu trả lời “chưa cần boundary riêng vì một team, một policy và change thấp; xem lại khi external sync xuất hiện” có giá trị hơn việc tạo nhiều interface để mọi ô đều xanh.


Từ class đến kiến trúc thay đổi được

Cuốn sách bắt đầu bằng coupling, cohesion và cost of change. Chương 2 cung cấp cơ chế Spring: bean, IoC và constructor injection. Chương 3–7 lần lượt kiểm soát responsibility, extension, substitution, client surface và dependency direction. Chương 8 biến các boundary ấy thành testing evidence. Chương 9 đặt chúng trong package, module và model boundary của một application hoàn chỉnh.

Năm nguyên lý không hoạt động như năm checkbox độc lập. SRP giúp nhận ra axis of change. OCP yêu cầu extension point có chọn lọc. LSP giữ các implementation trung thực với contract. ISP ngăn public surface phình rộng. DIP bảo vệ policy khỏi detail. Khi phối hợp, chúng giúp team thay đổi một phần mà không cần hiểu và sửa toàn hệ thống.

SOLID không bảo đảm mọi thiết kế đầu tiên đều đúng. Domain knowledge phát triển, boundary sẽ cần di chuyển và abstraction có thể sai. YAGNI, Rule of Three và wrong abstraction vẫn áp dụng: bắt đầu từ evidence, refactor theo bước nhỏ, kiểm chứng behavior và điều chỉnh structure khi pattern thay đổi đủ rõ.

Phần không thể thương lượng là tính trung thực của dependency và contract. Một hệ thống đơn giản có thể không cần Hexagonal đầy đủ. Nhưng nếu tài liệu nói domain độc lập trong khi domain import vendor SDK, hoặc module nói internal trong khi mọi client truy cập trực tiếp, architecture đang mô tả điều code không thực thi.


Tóm tắt

  • Architecture được đánh giá bằng cost of change và dependency thực tế, không bằng số layer hoặc vẻ ngoài diagram.

  • Horizontal layer, application module, Bounded Context và deployment boundary giải quyết các câu hỏi khác nhau; không nên đồng nhất chúng.

  • Hexagonal Architecture bảo vệ application core bằng purposeful ports và technology adapters; hình lục giác không quy định số lớp.

  • Package by domain cải thiện navigation, nhưng public API, access control và architecture tests mới thực thi module boundary.

  • Bounded Context bảo vệ meaning của model; cùng ID hoặc table không chứng minh hai context nên chia sẻ entity.

  • ArchUnit và Spring Modulith bổ sung structural evidence; chúng không thay behavior, contract và adapter integration tests.

  • Modular monolith là baseline hợp lý khi chưa có evidence cho independent deployment; microservices không phải cấp độ cao hơn của SOLID.

  • Áp dụng architecture theo rủi ro: Enrollment cần boundary sâu, CourseTagAdmin hiện chỉ cần cấu trúc đơn giản và trigger xem xét lại.

  • SOLID là hệ thống câu hỏi về change, contract và dependency; giá trị nằm ở quyết định có evidence, không ở số abstraction.


Bài tập và capstone cuối sách

Phần bài tập và capstone cuối chương yêu cầu bạn phân tích dependency graph, xác định Bounded Context, refactor package-by-layer, viết ArchUnit rules, lựa chọn giữa direct API, port và event, đồng thời bảo vệ quyết định không over-engineer một CRUD module.

Hỏi đáp

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