logoMột hệ thống khó hiểu thì cũng khó thay đổi
Hexagonal Architecture - Phần 3: Domain Entity Validation

Series bài viết về Hexagonal:

Phần trước, chúng ta đã sắp xếp lại các class giữa các lớp để dần chuyển sang Hexagonal. Phần này, ta sẽ đi vào chi tiết việc refactoring với use case Account Creation.

Phần 3: Tiếp tục refactoring: quá trình tạo mới và cơ chế Validate Domain Entity

Tạo mới đối tượng nghiệp vụ

Lõi business đang được đặt với tên Service Layer - là cái tên giữ lại từ Layered Architecture. Ta sẽ đặt cho nó cái tên mới. Do ở bước này, chúng ta đang tạo ra lõi ứng dụng, là nơi tập hợp các use case và độc lập với công nghệ, ta có thể lựa chọn: Application Layer, hoặc Business Layer, hoặc Use Cases Layer. Ở đây, ta lựa chọn Application Layer nhé.

Hình 1: Application Layer bao gồm hai Input Port và một Output Port

Hình 1 bên trên cho thấy Application Layer hiện tại đang có hai use case tương ứng với hai Input Ports AccountCreation và AccountLogin. Cùng xem qua code, cũng tương tự với Layered Architecture:

    public void createAccount(AccountDto accountDto) throws InvalidCredentialsException {
        isEmailAlreadyUsed(accountDto.email());
        var user = new Account(accountDto.name(), accountDto.password(), accountDto.email());
        accountRepository.save(user);
    }

    private void isEmailAlreadyUsed(String email) throws InvalidCredentialsException {
        if (accountRepository.findByEmail(email).isPresent()) {
            throw new InvalidCredentialsException("Email address already exists");
        }
    }

Như tôi đã đề cập, Hexagonal Architecture đi cùng DDD sẽ tạo ra bức tranh vô cùng đẹp đẽ. Code trên, vì nghiệp vụ rất đơn giản, đã có thể coi là hướng đối tượng. Nhưng giờ ta sẽ improve nó lên một bậc nữa nhé. 

Nếu như nghiệp vụ này, tôi quy định rằng: Bất cứ Account nào được tạo ra trong Application Layer, nó phải vượt qua mọi cơ chế validation của nghiệp vụ. Thì code trên chưa đáp ứng được. Những người sử dụng lõi nghiệp vụ này, là Application Layer, trong dịch vụ triển khai của họ, họ sẽ luôn có khả năng tạo ra một account mới chỉ với new Account(...). 

Để làm được điều này, trước hết, ta cần "giấu" đi các constructor của Account class, bằng cách bỏ "public" phía trước, và để là default. Với điều chỉnh này, các class nằm cùng package với Account, hay nói một cách "nghiệp vụ" hơn là nằm cùng module với Account, mới có thể gọi tới constructor đó. Thế giới bên ngoài, dù ở bất cứ package nào cũng sẽ không thể new Account(...).

Nhưng đây mới chỉ là một nửa câu chuyện. Nửa còn lại là vẫn phải cung cấp API cho bên ngoài tạo Account và Account được tạo ra luôn luôn valid. UseCaseImpl class không làm được vì nó khác package với Account nên nó không thể gọi new Account(...).

Trong DDD, ta có thể sử dụng hai lựa chọn sau: Sử dụng Factory Pattern hoặc Domain Service. Ở đây, tôi sẽ sử dụng Domain Service với cái tên: AccountCreationService. Chú ý rằng, nó không phải là tầng service thông thường như ở Layered, nó là một "building block" làm nên DDD (Domain-Driven Design), nó nằm cùng package với Account và hợp cùng với Account tạo thành một module nghiệp vụ có tính cohesion cao.

Domain Service

Tôi sẽ tạo AccountCreationService dưới dạng class vì có thể không có thuật toán tạo Account nào khác sau này theo suy tính của tôi. Như vậy, ta có sơ đồ sau:

Hình 2: AccountCreationService phụ trách việc tạo mới một Account luôn luôn hợp lệ.

Trong AccountCreationService, ta sẽ tạo mới Account và sẽ trả về một Account nếu nó vượt qua mọi cơ chế validation, nếu không, service sẽ báo lỗi.

Nhưng validate một Account sẽ làm như thế nào?

  • Trước hết, việc validate một Account cần nằm trong Domain vì đó là nghiệp vụ. Nếu ta để validate ra bên ngoài, không có gì đảm bảo rằng các cơ chế validate đó được triển khai tại mọi nơi domain đó được áp dụng.
  • Ngay cả việc validate cũng chia làm các cấp độ khác nhau, theo đó sẽ là các đối tượng tham gia khác nhau
    • Mức field thuộc tính của Account.
    • Mức toàn bộ class Account.
    • Mức giữa các đối tượng Account với nhau.

Account Validation

Mức thuộc tính và mức toàn object

Với ví dụ hiện tại, giả sử, ta cần validate email xem có đúng cấu trúc hay không (cấp thuộc tính), và, giả sử, ta bắt buộc email và name phải khác nhau (cấp toàn object).

public Account(String email, String name, String password) throws InvalidCredentialsException {
    if (validateEmail(email)) {
        this.email = email;
    } else {
        throw new InvalidCredentialsException("Email is invalid.");
    }
    if (name.equalsIgnoreCase(email)) {
        throw new IllegalArgumentException("Name and email must be different");
    }
    this.name = name;
    this.password = password;
}

Như vậy, ta đã để tất cả vào trong constructor. Nếu sử dụng setters, bạn có thể đặt các validation này trong đó.

Mức giữa các object

Nhưng còn cấp kiểm tra giữa các object thì sao, điều kiện là: các Account không được trùng email. Cấp kiểm tra này ngoài phạm vi của chính bản thân đối tượng Account đang được tạo. Hiểu theo lẽ thường là: tôi không biết được tôi trùng lặp với ai trong mọi người. Vì thế cần cấp validation cao hơn phụ trách, và ta gọi nó là AccountValidator. Class này như một "thần thánh" nhìn từ trên xuống và biết được ai trùng với ai. Account lúc này cần làm gì? Nó chỉ cần chìa ra một API để "thần thánh" gọi, đó là method validate().

Cách phản ứng với dữ liệu invalid cũng cần được đề cập. Thông thường, trong quá trình validate, ta sẽ raise exception ngay khi bắt gặp invalid. Nhưng có những tình huống ta cần trì hoãn việc này lại và đi tiếp quá trình cho tới hết để ta gom lỗi và gửi lại phía user, giúp cho họ có thể chỉnh sửa nhiều thông tin cho hợp lệ trong cùng một lần. 

(Chủ đề validation trong DDD sẽ được bàn tới chi tiết trong bài viết khác.)

Ở đây ta có một class giúp ta có thể định nghĩa cách phản ứng khi gặp một dữ liệu invalid: 

public interface ValidationNotificationHandler {
    void handleError(String message);
}
public class AccountValidationNotificationHandler implements ValidationNotificationHandler {
    List messages = new ArrayList<>();
    @Override
    public void handleError(String message) {
        this.messages.add(message);
    }
    //...

Phương thức handleError() không throws ngay exception nếu được gọi mà nó tích lũy các lỗi lại, đến cuối nó sẽ gửi lại danh sách lỗi:

AccountValidationNotificationHandler notificationHandler = new AccountValidationNotificationHandler();
account.validate(this.accountRepository, notificationHandler);
if (notificationHandler.messages().isEmpty()) {
    return account;
} else {
    throw new InvalidCredentialsException("Invalid credentials. Reasons: " + notificationHandler.concatenatedMessage());
}

 

Quá trình validate Account đã trì hoãn tới cuối cùng mới throw exception nếu có lỗi.

Để ý thấy method account.validate() có input là AccountRepository. Ta cần nó để AccountValidator truy cập và kiểm tra xem có account nào trùng email trong database không:

public void validate(AccountRepository accountRepository, ValidationNotificationHandler validationNotificationHandler) {
    (new AccountValidator(this, accountRepository, validationNotificationHandler)).validate();
}

Quá trình validate Account được ủy quyền hoàn toàn cho AccountValidator:

    @Override
    public void validate() {
        if (this.emailAlreadyExist(this.account)) {
            this.validationNotificationHandler.handleError("The Email is already exist.");
        }
        // others validations
    }

Bạn có thể thấy rõ cách AccountValidator hoạt động: Nó có thể validate logic bất kỳ của Account và khi gặp lỗi, nó sẽ ủy quyền kiểm soát lỗi cho ValidationNotificationHandler, class này có thể trì hoãn thông báo lỗi để tích lũy tất cả các lỗi trong quá trình validate, hoặc thông báo ngay (bằng throws exception) nếu gặp tình huống phù hợp.

Tới đây, logic tạo account đang tập trung tại Domain Service AccountCreationService:

public Account createAccount(String email, String name, String password) throws InvalidCredentialsException {
    Account account = new Account(email, name, password);
    AccountValidationNotificationHandler notificationHandler = new AccountValidationNotificationHandler();
    account.validate(this.accountRepository, notificationHandler);
    if (notificationHandler.messages().isEmpty()) {
        return account;
    } else {
        throw new InvalidCredentialsException("Invalid credentials. Reasons: " + notificationHandler.concatenatedMessage());
    }
}

Account được tạo bằng new Account(...) chỉ là một bản draft. Nó được trải qua quá trình validation bằng AccountValidator. Nếu vượt qua, nó được trả lại cho bên gọi, ở đây là AccountCreationImpl, ngược lại, nó sẽ throw một exception.

Vấn đề phát sinh

Tới giờ có thể bạn nhận ra một vấn đề quan trọng: AccountRepository nằm tại Application Layer lại được tham chiếu bởi AccountCreationService nằm tại Domain Layer. Ta đang vi phạm một số nguyên lý:

  • Nguyên lý DIP. Vì Domain Layer ở mức cao hơn Application Layer, nó không nên phụ thuộc vào Application Layer.
  • Nguyên lý Acyclic Dependency Principle (ADP). Phụ thuộc giữa các thành phần không tồn tại chu trình. Ở đây, Application Layer phụ thuộc vào Domain Layer, và Domain Layer cũng phụ thuộc vào chính Application Layer.
Hình 3: AccountCreationService phụ thuộc vào AccountRepository khiến thiết kế vi phạm DIP và ADP

Ta cần giải quyết vấn đề này. 

Ta có một số phân tích sau:

  • AccountRepository là một Output Port trong kiến trúc Hexagonal, nó được đặt tại Application Layer là không sai. Nhưng Application Layer lại phụ thuộc vào Domain Layer - là nơi chứa các nghiệp vụ lõi nhất của ứng dụng, mà trong đó, một số logic lại cần cơ chế truy xuất Domain Entity, hay cụ thể hơn là Aggregate trong DDD.
  • Mặt khác, theo thiết kế chiến thuật (Tactical Design) trong DDD, Repository là một "building block" cho phép ta truy xuất các Aggregate từ thế giới bên ngoài.

Kết hợp cả hai điều này, ta sẽ "kéo" AccountRepository về Domain Layer:

Hình 4: AccountRepository được đặt tại Domain Layer (hình đã lược bỏ AccountLogin để dễ nhìn hơn)

Hình 4 cho thấy chỉ cần đưa AccountRepository về Domain Layer, lập tức cả hai vi phạm trên được giải quyết: Chỉ có các mũi tên phụ thuộc hướng từ Application Layer trỏ tới Domain Layer, do đó, thiết kế này đảm bảo tính DIP và ADP.

Source code: Hexagonal Architecture Demo

(Bài này tôi lại tạm dừng ở đây do cũng đã tương đối dài, tiếp tục có thể gây khó theo dõi. Bài tiếp theo sẽ đi tiếp về quá trình refactoring AccountLogin use case và phần Adapters)

Bình luận
Gửi bình luận
Bình luận