logoMột hệ thống khó hiểu thì cũng khó thay đổi
Hexagonal Architecture - Phần 4: Hoàn thiện quá trình chuyển đổi

Series bài viết về Hexagonal:

Phần trước, chúng ta đã refactor xong nghiệp vụ tạo mới account, trong đó, ta giải quyết một số vấn đề về việc tạo mới Account, validate nó và giải quyết một số vi phạm thiết kế. Phần này, ta sẽ hoàn thiện quá trình refactoring này.

Phần 4: Hoàn thiện quá trình refactoring

Account Login

Còn use case AccountLogin cần được refactor sang Hexagonal. Hiện trạng tại Layered Architecture đang là method login() trong AccountService:

public class AccountService {
    private final AccountRepository accountRepository;
    
    public void login(String email, String password) throws InvalidCredentialsException {
        var optionalAccount = accountRepository.findByEmail(email);
        if (optionalAccount.isPresent()) {
            var account = optionalAccount.get();
            var isThePasswordValid = isThePasswordValid(account.getPassword(), password);
            if (!isThePasswordValid) {
                throw new InvalidCredentialsException("Invalid credentials");
            }
        } else {
            throw new InvalidCredentialsException("Invalid credentials");
        }
    }
    
    private boolean isThePasswordValid(String password, String uiPassword) {
        return password.equals(uiPassword);
    }
    ...

Code cũng khá sáng sủa, dễ hiểu giống như phần tạo mới account ban đầu. Nhưng, một lần nữa, code này là Transaction Script. Ta sẽ giúp nó hướng đối tượng hơn.

Account login là một use case khác so với Account Creation, do đó, ta sẽ để nó trong một use case riêng với API riêng.

Hình 1: Use case login được khai báo với một Input Port AccountLogin và một Use Case Interator (hay Use Case Implementation) AccountLoginImpl

API login bao gồm AccountLogin interface và một DTO làm input param LoginInput:

public interface AccountLogin {
    LoginOutput login(LoginInput loginInput);
}

public record LoginInput(String email, String password) {}

public record LoginOutput(boolean isSuccess, String message) {}

Theo quy ước thường lệ mà tôi luôn đề cập, với những method dạng truy vấn, ta nên trả về kết quả, ngược lại, với những method dạng cập nhật, ta nên để void. Trường hợp này, login là method truy vấn (giả sử ta không tác động gì tới dữ liệu như cờ trạng thái login, thời gian bắt đầu login,...) nên ta sẽ trả về kết quả LoginOutput. Kết quả này chỉ đơn giản là báo thành không hay không qua isSuccess, nếu thất bại, một message cho biết lý do.

Ở cách viết Transaction Script, cần thiết phải lấy ra password của account và so sánh với password đưa vào. Điều này ta có thể tối ưu hơn bằng cách không cho Account có method getPassword() (hay password()), thay bằng method verifyPassword() của chính Account:

public boolean verifyPassword(String password) {
    return this.password.equals(password);
}

Tính đóng gói của Account tăng lên đáng kể chỉ với một method đơn giản, password của nó đã không bị phơi ra bên ngoài qua getPassword(). Lúc này, nó như có một tuyên bố: Bạn lấy tôi ra từ database với email. Ok, vậy đưa "chìa khóa" đây để tôi kiểm tra xem có đúng không?

Hơn nữa, Account còn được trang bị thêm hành vi nghiệp vụ, là những gì mà ta đang hướng đến trong Hexagonal. Account đã thực sự không còn là một data class thuần túy.

Nghiệp vụ login lúc này được điều phối bởi Use Case Interactor AccountLoginImpl:

public LoginOutput login(LoginInput loginInput) {
    Optional optionalAccount = accountRepository.findByEmail(loginInput.email());
    if (optionalAccount.isPresent()) {
        Account account = optionalAccount.get();
        if (account.verifyPassword(loginInput.password())) {
            return new LoginOutput(true, null);
        } else {
            return new LoginOutput(false, "Password is incorrect");
        }
    } else {
        return new LoginOutput(false, "Email not found");
    }
}

Code không giảm đi theo kỳ vọng so với Transaction Script. Đừng vội thất vọng vì phần lớn code bên trên là code "Interactor" - thể hiện sự tương tác, điều phối theo đúng vai trò của nó. Có thể trong những nghiệp vụ phức tạp hơn, bạn sẽ thấy được giá trị của hướng đối tượng.

Tổng kết lại quá trình refactoring tầng ứng dụng lại như sau:

  • Ứng dụng được triển khai trong một module riêng biệt, hướng tới POJO classes để tránh phụ thuộc vào công nghệ bên ngoài, giữ cho nghiệp vụ đúng là nghiệp vụ.
  • Sơ đồ package như sau, bạn có thể sử dụng cho những project sắp tới của mình (tuy nhiên, đây chỉ là một lựa chọn, quan trọng là bạn đã nắm được ý tưởng của Hexagonal):
Hình 2: Cấu trúc package trong ứng dụng

Hình 2 thể hiện một số thông tin:

  • Tên của thư viện (module) là account-application. Thư viện này sẽ được phát hành theo nguyên lý REP.
  • Package application là Application Layer mà ta đã tạo ra. Đây là nơi khai báo Input Ports, Output Ports, và Use Case Interactors của ứng dụng.
  • Package domain.model là Domain Layer, khai báo các thành phần tạo nên lõi nghiệp vụ của domain như Entities, Value Objects, Repositories (của các Aggregates), Factories (của các Aggregates), Domain Service, và các Domain Events.

Cùng xem bên trong từng package:

Hình 3: Cấu trúc chi tiết bên trong từng package

Trong hình 3, bạn có thể thấy lạ lẫm với package domain.model khi tôi gom tất cả những gì thuộc về Account Domain vào cùng một nơi như vậy. Bình thường, với Layered, các class data sẽ đặt hết trong package "entities", các class thao tác với database sẽ đặt hết trong "repositories". Cách làm đó làm giảm đi tính module hóa của ứng dụng, cụ thể thì tính cohesion thấp. Ta gom những thứ liên quan chặt chẽ với nhau vào cùng 1 package để nhằm tạo ra những package có tính cohesion cao, chúng sẽ hướng đến cùng một mục tiêu và không thể tách rời. Bạn có thể tham khảo thêm ở CCP.

Để tối ưu hơn, ta có thể tách các class Validator, ValidationNotificationHandler ra một thư viện dùng chung.

Package ports.outbound rỗng vì ta đã di chuyển AccountRepository vào Domain Layer theo DDD nhưng không có nghĩa là nó luôn như vậy. Có những use case cần những Output Ports không thuộc vùng Domain Layer thì những ports này sẽ nằm tại đây.

Ứng dụng của ta đã khá hoàn thiện, giờ là lúc cần quyết định xem nó được triển khai ở đâu, cơ chế lưu trữ là gì, framework sử dụng là gì. Đó là những quyết định mà chúng ta đã trì hoãn suốt từ đầu khi phát triển ứng dụng với Hexagonal. Ta cùng đi tới tầng Adapter Layer trong kiến trúc này.

Triển khai

Giống như Layered, ta sẽ triển khai ứng dụng này dưới dạng web service, lưu trữ sử dụng H2. Thực ra, gọi là đưa ra quyết định chứ đây là quá trình refactoring một ứng dụng có sẵn, thường thì các giao diện như RESTful APIs, Database sẽ được giữ nguyên sau quá trình này.

Vai trò

Đây là tầng công nghệ nên ta có thể thoải mái áp dụng bất cứ framework hay công nghệ nào mà ta cảm thấy phù hợp. Để ví dụ, tôi sẽ sử dụng Spring Web và Spring JPA, và ta gọi đây là service. Service cần khai báo dependency account-application:

< dependency >
    < groupId >vn.softwaredesign.architecture< / groupId >
    < artifactId >account-application< / artifactId >
    < version >0.0.1< / version >
< / dependency >

Tại Adapter Layer, ta cần định nghĩa những cơ chế giao tiếp cụ thể với các Input Ports và Output Ports của tầng ứng dụng:

Hình 4: Các Input Adapter và Output Adapter (màu đỏ) được triển khai trong Adapter Layer để hoàn thiện service

Trong hình 4 bên trên, ta có các adapter sau:

  • Input Adapters: Là những adapter tiếp nhận yêu cầu từ bên ngoài sau đó thực hiện một số nghiệp vụ đơn giản (không nằm trong nghiệp vụ ứng dụng), chuẩn bị tham số để gửi tới Input Ports tương ứng. Cụ thể trong ví dụ:
    • AccountController: là dạng RESTful APIs, tiếp nhận HTTP Request từ phía người dùng.
    • EventConsumer: là các thành phần pull messages từ một Message Queue, chuyển thành lời gọi ứng dụng bên trong. Ở trên hình, tôi để cụ thể là "Command Queue" là một Message Queue chứa các yêu cầu từ bên ngoài gửi đến, thông thường là các dịch vụ khác.
  • Output Adapters: Là những adapter giúp service giao tiếp với thế giới bên ngoài như service khác, database, message queue, file system,...
    • AccountRepositoryImpl: là thực thi của Output Port AccountRepository (trong hình tương ứng với mũi tên "realize"). Adapter này giao tiếp với database, công việc của nó sẽ bàn tới bên dưới.
    • EventProducer: nếu service cần thông báo qua một Message Queue? EventProducer sẽ giúp làm điều đó.

Giao tiếp với database

Trong ví dụ của ta, giao tiếp với database sẽ là AccountRepository và AccountRepositoryImpl vì đã quyết định Account được lưu tại database.

Hình 5: Adapter AccountRepositoryImpl đứng ở giữa Input Port AccountRepository của ứng dụng và database thuộc về công nghệ bên ngoài, phụ trách giao tiếp giữa hai thế giới

Hình 5 cho thấy adapter AccountRepositoryImpl đứng giữa, giúp ứng dụng Application Layer có thể giao tiếp được với các công nghệ bên ngoài, trường hợp này là database.

Một vấn đề đặt ra: Bên trong ứng dụng là một mô hình thực thể mà ta gọi là Domain Model, nó được biểu diễn bằng ngôn ngữ lập trình mà ta sử dụng. Còn bên ngoài, ở phía database, là một mô hình dữ liệu thực thể quan hệ Entity Relationship ở dạng logic (mô hình logic). Hai mô hình này thông thường là khác nhau, dù trong ví dụ này chúng khá tương đồng Account class và accounts table. Thêm nữa, mô hình dữ liệu ở phía công nghệ có thể thay đổi, lúc này là một Relational Database, lúc khác có thể là Document Database, lúc khác nữa có thể là File System,... 

Vần đề đã được xác định: Ta cần một cơ chế ánh xạ giữa hai mô hình.

Hình 6: Một cơ chế ánh xạ dữ liệu giữa hai mô hình Domain Model và Database Model (Data Model) được thực thi bởi Output Adapter

Cùng hiện thực hóa nó qua code...

Output Adapter AccountRepositoryImpl:

public class AccountRepositoryImpl implements AccountRepository {
    private final JpaAccountRepository jpaAccountRepository;
    ...

Output Adapter đang "realizes" interface Output Port AccountRepository. Bên trong có khai báo một JpaAccountRepository. Đó chính là AccountRepository mà ta đã gặp ở Layered Architecture. Nó là Repository của Spring framework.

public interface JpaAccountRepository extends JpaRepository {
    Optional findByEmail(String email);
}

Tương ứng với nó là một data class:

@Entity
@Table(name = "accounts")
@Getter
@Setter
public class JpaAccount {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "email", nullable = false, unique = true)
    private String email;
}

JpaAccount chính là DTO giao tiếp giữa database và service.

Quay lại Output Adapter AccountRepositoryImpl, như đã nói, nó cần chứa một cơ chế ánh xạ giữa hai mô hình dữ liệu:

  1. Ánh xạ từ Domain Model sang Database Model: được gọi trong trường hợp cần save dữ liệu từ service vào database. Ở đây là method: save().
  2. Ánh xạ từ Database Model sang Domain Model: được gọi trong trường hợp cần lấy dữ liệu từ database vào service, cụ thể là application. Method tương ứng là findByEmail().

public class AccountRepositoryImpl implements AccountRepository {
    private final JpaAccountRepository jpaAccountRepository;
    
    @Override
    public Optional findByEmail(String email) {
        Optional jpaAccount = jpaAccountRepository.findByEmail(email);
        return convertToDomainModel(jpaAccount);
    }
    
    @Override
    public void save(Account account) {
        JpaAccount jpaAccount = convertToDatabaseModel(account);
        jpaAccountRepository.save(jpaAccount);
    }
    ...

Hai method ánh xạ dữ liệu là convertToDomainModel() và convertToDatabaseModel().

Hoàn thiện 2 method này không khó, cơ bản là như sau:

private Optional convertToDomainModel(Optional jpaAccount) {
    if (jpaAccount.isPresent()) {
        return new Account(...);
    } else {
        return Optional.empty();
    }
}

Ta gặp vấn đề liên quan tới new Account(...) vì trước đó đã thống nhất "giấu" đi constructor của nó để đảm bảo tính "always valid".

Khắc phục vấn đề phát sinh khi ánh xạ Domain Model và Database Model

Để khắc phục vấn đề này ta những giải pháp sau:

Cách 1: Gỡ bỏ hạn chế truy cập constructor của Account. 

Cách này dễ dàng thực hiện được và thật sự không gây ảnh hưởng xấu quá nhiều tới nghiệp vụ tạo mới Account. Ta vẫn giữ việc tạo mới qua Input Port, đồng thời vẫn để public contructor.

Cách 2: Tạo một class "gián điệp" nằm cùng package với Account.

Cách này về cơ bản là hoạt động được nhưng tôi thà sử dụng cách 1 còn hơn vì vi phạm một chút về nguyên tắc nhưng đạt được nhiều lợi ích hơn.

Cách 3: Sử dụng một class "gián điệp" trong lòng Account: Memento Pattern.

Bạn có thể xem thêm Memento Pattern ở đây. Ý tưởng của nó là có một class nằm bên trong class A. Ta muốn lưu lại trạng thái A như một bản backup và có thể restore khi cần.

Nhưng đây không phải là lý do chính mà tôi đưa Memento vào đây. Ta cùng bàn sâu hơn nữa.

Thông thường, khi tạo ra một class, nhất là Domain Model, là những class chứa dữ liệu nghiệp vụ và hành vi nghiệp vụ. Cuối cùng, ta thường vẫn phải save nó vào đâu đó như một database. Vậy cuối cùng, ta vẫn phải có các hàm getters cho các thực thể đó dù trước đó ta đã cố gằng hạn chế loại phương thức này, vì

  • Chúng làm mất tính đóng gói của đối tượng nghiệp vụ. Với từng getter cho từng field, nó làm lộ hết những gì nó có khiến cho các user có thể sử dụng thoải mái, dẫn tới code khó maintain.
  • Ngoài ra, loại phương thức này còn gây ra sự sao nhãng khi bạn sử dụng "ctrl + space" để list ra những phương thức có thể trong quá trình lập trình. Giữ cho giao diện của một đối tượng càng tinh gọn thì khả năng bảo trì và kiểm soát code càng tốt hơn.

Vậy làm thế nào để chúng ta có thể vừa giữ cho class tinh gọn lại vừa có thể lấy hết thông tin ra để lưu vào database?

Đây là nơi để Memento tỏa sáng. Cùng xem nhé:

public class Account {
    private String email;
    private String name;
    private String password;
    ...
    public static class AccountMemento {
        private String email;
        private String name;
        private String password;
        public AccountMemento(String email, String name, String password) {
            this.email = email;
            this.name = name;
            this.password = password;
        }
        public String email() {
            return email;
        }
        public String name() {
            return name;
        }
        public String password() {
            return password;
        }
    }
}

Ta đã khai báo một AccountMemento nằm trong lòng Account, do đó, nó có thể truy cập được mọi thuộc tính của Account. Nó còn chìa ra các public getters để bên ngoài có thể sử dụng.

Ta sẽ áp dụng vào hai method ánh xạ dữ liệu.

private Optional convertToDomainModel(Optional optionalJpaAccount) throws InvalidCredentialsException {
    if (optionalJpaAccount.isPresent()) {
        JpaAccount jpaAccount = optionalJpaAccount.get();
        Account.AccountMemento accountMemento = new Account.AccountMemento(jpaAccount.getEmail(), jpaAccount.getName(), jpaAccount.getPassword());
        return Optional.ofNullable(accountMemento.restore());
    } else {
        return Optional.empty();
    }
}

Có thể bạn thấy lạ lẫm với cách dùng Memento của tôi. Đáng lẽ nó phải thế này chứ:

Có thể 1:

Account account = new Account();
account.restore(accountMemento);
return Optional.ofNullable(account);

Cách làm này thường gặp với Memento nhưng làm thế, ta phải khai báo thêm một public default constructor cho Account, là điều ta đang cố gắng tránh nếu có thể.

Có thể 2:

Account account = Account.restore(accountMemento);
return Optional.ofNullable(account);

Tôi thấy "có thể 2" là chấp nhận được. Nhưng ở đây, tôi lựa chọn cách: Để Memento restore đối tượng cha của nó.

Tới giờ, việc convert từ Domain Model sang Database Model đã dễ dàng hơn rất nhiều rồi: Đầu tiên, từ Account, ta lấy ra Memento của nó, sau đó sử dụng Memento để tạo ra JpaAccount:

public class Account {
    ...
    public AccountMemento createSnapshot() {
        return new AccountMemento(this.email, this.name, this.password);
    }

Method createSnapshot() trong Account giúp tạo ra một phiên bản backup của chính nó, là một AccountMemento.

private JpaAccount convertToDatabaseModel(Account account) {
    Account.AccountMemento memento = account.createSnapshot();
    
    JpaAccount jpaAccount = new JpaAccount();
    jpaAccount.setEmail(memento.email());
    jpaAccount.setName(memento.name());
    jpaAccount.setPassword(memento.password());
    
    return jpaAccount;
}

Tới đây, mọi thứ cơ bản đã xong. Bạn có thể có thắc mắc: đã đóng gói Account rồi thì tạo ra Memento rồi mở tung ra thì lại thành ra phí công sức.

Nếu ta có quy định rõ ràng: Memento là pattern chỉ được sử dụng ở tầng Adapter Layer thì sao? Điều này là hợp lý. Và vì những lợi ích do việc đóng gói tốt mang lại thì thêm một quy định cũng không phải không đáng để đánh đổi.

Còn một thứ nữa tôi muốn bàn đến để kết thúc tốt đẹp kiến trúc Hexagonal: Kiểm soát transaction.

Kiểm soát transaction

Application Layer đang là POJOs. Nếu ta đưa vào các cơ chế kiểm soát transaction như @Transactional của Spring thì sẽ phá vỡ khối POJO này.

Hiện tại, có thể bạn đang nghĩ: AccountController sử dụng AccountCreation interface?

Điều này cũng hợp lý nhưng hãy xem kỹ lại:

@Transactional
public ResponseEntity createAccount(@RequestBody AccountDto accountDto) {
    try {
        accountCreation.createAccount(accountDto);
        return ResponseEntity.status(HttpStatus.CREATED).body("Account created successfully");
    } catch (Exception e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
    }
}

(Chú ý @Transactional bên trên method)

Đây là dạng code thường thấy ở Controller class, và cách code này là hợp lý: Ta không nên throw trực tiếp exception từ backend cho giao diện người dùng mà hãy catch chúng, sau đó chuyển thành các Response có nghĩa.

Vậy khi catch các exception này lại, @Transactional không biết được method có lỗi để rollback thay đổi trong database. Vậy ta cần cách code khác.

Giải pháp

Thói quen trong Layered Architecture ở part 1 sẽ giúp ta: Tạo ra một lớp service mỏng gọi là Application Service đóng vai trò:

  • Tiếp nhận lời gọi từ controller, chuẩn bị một số dữ liệu cho Input Port API và gọi API đó.
  • Kiểm soát transaction

@Service
@RequiredArgsConstructor
public class AccountApplicationService {
    private final AccountCreation accountCreation;
    private final AccountLogin accountLogin;

    @Transactional
    public void createAccount(AccountDto accountDto) throws InvalidCredentialsException {
        this.accountCreation.createAccount(accountDto);
    }

    public void login(String email, String encodedPassword) throws InvalidCredentialsException {
        LoginInput loginInput = new LoginInput(email, encodedPassword);
        LoginOutput loginOutput = this.accountLogin.login(loginInput);
        if (!loginOutput.isSuccess()) {
            throw new InvalidCredentialsException(loginOutput.message());
        }
    }
}

Giờ thì, AccountController không giao tiếp trực tiếp với Input Ports mà thông qua Application Service này:

@PostMapping("/create")
public ResponseEntity createAccount(@RequestBody AccountDto accountDto) {
    try {
        accountApplicationService.createAccount(accountDto);
        return ResponseEntity.status(HttpStatus.CREATED).body("Account created successfully");
    } catch (Exception e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
    }
}

Quá trình refactoring tới đây là kết thúc. Chân thành cảm ơn các bạn đã kiên trì theo dõi.

Code demo cho loạt bài này: hexagonal-architecture-demo

 

 

 

 

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