Chào mừng các bạn đến với bài viết của mình!
Đây là bài viết về Clean Architecture phần 2, nên sẽ rất khó hiểu cho các bạn chưa xem phần 1. Cho nên các bạn hãy quay lại phần 1 xem trước nhé.
Ok, mình đi vào việc! Trong bài này chúng ta cùng nhau code cho use case ở trong phần một là tạo một author user.
Quay lại cái hình phần một để các bạn nhớ lại phần sơ đồ kiến trúc cho use case đó nhen.
Các bạn hãy xem qua một chút sơ đồ trên để mình dễ code hơn.
Bắt đầu code thôi nào.
(À vì những thành phần trong use case cũng không có gì phức tạp nên mình sẽ không design UML (Unified Modeling Language) diagram cho các thành phần ở trong use case).
Layer domain
Bởi vì mình có kết hợp với Domain Driven Design (DDD) nên ở đây sẽ là một nhóm các domain objects nha. User chính là object chính (aggregate root). Các bạn có thể đọc thêm về DDD trong các bài viết của mình.
Bên dưới là code nè! Đầu tiên là User
entity. Đây là entity chính trong chương trình, và trong DDD nó được xem là aggregate root
của User aggregate
.
// User.java
@Getter
@Builder
public class User extends AggregateRoot<Id> {
private UserName name;
private Email email;
private MobilePhone mobilePhone;
private String password;
UserActivated isActive;
UserDeleted isDeleted;
// Relationship with Role aggregate via id
private List<Id> roleIds;
public void updateName(UserName name) {
this.name = name;
}
public void updateEmail(Email email) {
this.email = email;
}
public void updateMobilePhone(MobilePhone mobilePhone) {
this.mobilePhone = mobilePhone;
}
public void addRole(Id roleId) {
if (roleIds.contains(roleId)) {
return;
}
roleIds.add(roleId);
}
public void removeRole(Id roleId) {
roleIds.remove(roleId);
}
public void activate() {
if (isActive == UserActivated.TRUE) {
throw new UserAlreadyActivatedException();
}
isActive = UserActivated.TRUE;
}
public void deactivate() {
if (isActive == UserActivated.FALSE) {
throw new UserAlreadyDeactivatedException();
}
isActive = UserActivated.FALSE;
}
public void markAsDeleted() {
if (isDeleted == UserDeleted.TRUE) {
throw new UserAlreadyDeletedException();
}
isDeleted = UserDeleted.TRUE;
}
}
Trong User
entity sẽ chứa các public method để cho bên ngoài thao tác. Nó còn chứa các business logic trong đó nữa.
Các business logics (nghiệp vụ) mà có thể kể đến như:
- Xóa user (markAsDeleted)
- Active hay deactive user
- Grant một role nào đó vào user
- ...
Còn đây là UserName
value object.
// UserName.java
@Getter
public class UserName {
private static final int MIN_LENGTH = 3;
private static final int MAX_LENGTH = 50;
private String value;
public UserName(String value) {
setValue(value);
}
private void setValue(String value) {
if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) {
throw new InvalidUserNameException();
}
this.value = value;
}
}
Các value objects khác mình sẽ không ghi vào đây vì sẽ rất dài. Các bạn hãy tự dựa vào đó mà thiết kế nha.
Bởi vì mình sẽ cần đăng ký UserCreatedEvent
trong domain layer nên mình sẽ tạo một domain service để tạo User entity, handle các business logic và register domain event.
// UserDomainServiceImpl.java implements UserDomainService.java
@Service
public class UserDomainServiceImpl implements UserDomainService {
@Override
public User createNewUser(UserDto userDto) {
User user = User.builder()
.name(new UserName(userDto.getName()))
.email(new Email(userDto.getEmail()))
.mobilePhone(new MobilePhone(userDto.getMobilePhone()))
.password(userDto.getPassword())
.isActive(UserActivated.TRUE)
.isDeleted(UserDeleted.FALSE)
.roleIds(userDto.getRoleIds().stream().map(Id::new).toList())
.build();
user.setId(new Id(UniqueIdGenerator.create()));
user.setAggregateVersion(CONCURRENCY_CHECKING_INITIAL_VERSION);
user.registerEvent(new UserCreatedEvent(user));
return user;
}
}
Ở đây thì có nhiều cách implement, mình thì thường dùng cách này, hoặc đôi lúc dùng factory method bên trong domain object luôn cho tiện cũng được. Tùy vào coding convention mà project của bạn quy định.
// User.java
@Getter
@Builder
public class User extends AggregateRoot<Id> {
// ...
// Có thể dùng Factory method ở đây để tạo User instance
public static User createUser() {
User user = User.builder()
.name(new UserName(userDto.getName()))
// ...
user.registerEvent(new UserCreatedEvent(user));
return user;
}
}
Trên đây là các thành phần ở Domain layer trong use case tạo User. Use case này cũng không phức tạp nên mình chỉ làm ở mức rất đơn giản vậy thôi.
Có 2 điểm bạn cần lưu ý ở đây
Đảm bảo về mặt business logics: Nghiệp vụ cần được thiết kế cẩn thận, tập trung tại Layer domain (và layer application) thôi. Tránh phân tán nghiệp vụ sang các tầng khác như database, controler, view, ... Đây là điều là mình gặp thường xuyên trong các project lớn.
Những nghiệp vụ nào liên quan tới model nào thì nên nằm ở model đó. Những nghiệp vụ mà kết hợp nhiều model (entity) lại với nhau, thì các bạn có thể tạo cho mình một domain service (trong DDD), còn không nữa thì có thể chuyển về các service ở layer application trong Clean architecture.
Tại sao mình lại nói như vậy? Nếu các bạn không code không cẩn thận, ví dụ nghiệp vụ phân tán khắp nơi, từ SQL, đến các layer, đến tầng view, ... Thì sau này khi khách hàng yêu cầu thay đổi spec một chút thôi. Các bạn sẽ thấy sự phức tạp khi thực hiện một việc thay đổi dù chỉ nhỏ. Nếu ngay ban đầu design đúng, thì việc maintain sau này cực kì dễ dàng.
Application layer
Ở đây mình sẽ implement use case CreateUserUseCase
như đã nói ở trên. Use case này như mình đã nói thì nó sẽ điều phối toàn bộ flow của request.
// CreateUserUseCaseImpl.java
@Service
@AllArgsConstructor
public class CreateUserUseCaseImpl implements CreateUserUseCase {
private UserRepository userRepository;
private RoleRepository roleRepository;
private UserDomainService userDomainService;
private PasswordEncoder passwordEncoder;
private UserEventPublisher publisher;
// Đây là flow chính của request.
public void execute(UserDto userDto) {
rolesExistOrError(userDto.getRoleIds());
userDoesNotExistOrError(userDto);
userDto.setPassword(passwordEncoder.encode(userDto.getPassword()));
User user = userDomainService.createNewUser(userDto);
userRepository.save(user);
publishDomainEvents(user);
}
private void userDoesNotExistOrError(UserDto userDto) {
Optional<User> user
= userRepository.findByEmail(userDto.getEmail());
if (user.isPresent()) {
throw new UserAlreadyExistsException();
}
}
private void rolesExistOrError(List<String> roleIds) {
List<Role> roles = roleRepository.findByIds(roleIds);
if (roles.size() != roleIds.size()) {
throw new RoleNotFoundException();
}
}
private void publishDomainEvents(User user) {
user.getDomainEvents().forEach(event -> publisher.publish(event));
}
}
Trên đây các bạn thấy file use case này đọc khá là đơn giản. Nhưng mình sẽ tập trung vào các điểm quan trọng.
- Nhắc lại, use case điều khiển flow chính của chương trình (request đó). Các bạn đọc hàm
execute
sẽ rõ. - Use case dùng các "nhân tố bên ngoài" để tương tác với các domain objects. Nhân tố bên ngoài ở đây chính là các thành phần ở layer ngoài như database, 3rd services, message brokers... Rõ ràng để tuân thủ theo dependency rule (ở phần 1), thì rõ ràng layer application (cụ thể là file use case này) không được phụ thuộc trực tiếp vào các layer bên ngoài. Vậy làm sao để thao tác với cơ sở dữ liệu ở đây? Mình sẽ đi chi tiết phần này ngay.
Nếu bây giờ, chúng ta gọi thẳng MySqlUserRepository (ngay dòng số 5 trong file use case) thì vi phạm dependency rule ngay. Thử tượng tượng, ở đâu đó trong tương lai, chúng ta muốn thay đổi code trong repo, hay đổi công nghệ, hoặc đơn giản là đổi tên class, ... Thì những thay đổi này, sẽ ảnh hưởng trực tiếp tới các tầng bên trong như application layer (ví dụ file change trên git sẽ có file use case này khi commit). Như thế là vi phạm tùm lum các nguyên tắc trong SOLID. Cho nên mới nói, đó chính là vi phạm dependency rule.
Vậy thì làm sao để tuân thủ được dependency rule ở đây, vì rõ ràng chúng ta buộc phải dùng repository để tương tác với các entity.
Dependency inversion principle (đảo ngược sự phụ thuộc) chính là chìa khóa. Thay vì layer application thục thuộc vào các layer bên ngoài, thì bây giờ, các layer bên ngoài phải phụ thuộc vào những quy định ở layer application. Layer application sẽ quy định những interface để giao tiếp với database như ví dụ sau:
// Tầng application sẽ quy định những interface trong UserRepositry
// Những layer ở ngoài phải implement interface này
public interface UserRepository {
void save(User user);
Optional<User> findById(String id);
Optional<User> findByEmail(String email);
void delete(User user);
}
Tương tự cho UserEventPublisher
cũng vậy, ở application layer chúng ta sẽ define những interface. Còn implement chi tiết sẽ nằm ở các layer ngoài như infrastructure layer.
Controller
Tiếp theo mình implement controller thôi.
// UserController.java
@AllArgsConstructor
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private CreateUserUseCase createUserUseCase;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public void createUser(@RequestBody UserDto user) {
createUserUseCase.execute(user);
}
}
Ở controller thì cực kì đơn giản thôi, ở đây mình đang dùng Java Spring Boot nên phần code càng đơn giản hơn.
Infrastructure
Tầng này chính là tầng bên ngoài, sẽ là các implement chi tiết mà các tầng bên trong quy định bằng interface. Nên mình sẽ không đi sâu phần này, các bạn xem qua một chút UserRepositoryImpl:
package com.lenhatthanh.blog.modules.user.infra.persistence;
@Component
@AllArgsConstructor
public class UserRepositoryImpl implements UserRepository {
private UserJpaRepository userJpaRepository;
@Override
public void save(User user) {
UserEntity userEntity = UserEntity.fromDomainModel(user);
userJpaRepository.save(userEntity);
}
@Override
public Optional<User> findById(String id) {
Optional<UserEntity> userEntity = userJpaRepository.findById(id);
return userEntity.map(UserEntity::toDomainModel);
}
@Override
public Optional<User> findByEmail(String email) {
Optional<UserEntity> userEntity
= userJpaRepository.findByEmail(email);
return userEntity.map(UserEntity::toDomainModel);
}
@Override
public void delete(User user) {
userJpaRepository.deleteById(user.getId().toString());
}
}
Ở đây mình dùng JPA Repository để implement repo.
package com.lenhatthanh.blog.modules.user.infra.persistence;
@Repository
public interface UserJpaRepository extends JpaRepository<UserEntity, String> {
Optional<UserEntity> findByEmail(String email);
}
Kết luận một chút
Thật ra, không phải lúc nào cũng nên áp dụng clean architecture đâu các bạn. Nếu từng dùng rồi thì chắc hẳn các bạn cũng thấy - nó tạo ra rất nhiều file, model, interface, nhiều layer… và điều đó khiến chương trình của chúng ta trở nên phức tạp hơn kha khá. Nhưng lợi ích mà nó mang lại thì không thể phủ nhận. Đúng là xứng đáng để đánh đổi, phải không?
Mình thì không thích nói mấy cái lý thuyết này lắm đâu, vì trên mạng nói đầy rồi =)). Thay vào đó, mình chia sẻ một chút kinh nghiệm thực tế nhé.
Khi làm việc trong các dự án lớn, có nhiều thành viên tham gia, thì việc vi phạm các nguyên tắc - đặc biệt là dependency rule -xảy ra rất thường xuyên. Và đôi khi, team của chúng ta vi phạm mà còn không hề hay biết. Vì vậy, cần có những thành viên kỳ cựu để review code, và phải có tài liệu hướng dẫn rõ ràng để các dev có thể áp dụng đúng. Nếu không, khi dự án ngày càng phình to, nó rất dễ biến thành một mớ hỗn độn. Dự án vài chục, vài trăm member là chuyện bình thường. Lúc này, toàn bộ dev team cần phải nắm vững triết lý của clean architecture. Để làm được điều đó thì phải đầu tư vào training, viết tài liệu, và đặc biệt là review code cho các thành viên mới. Đây là một khoản đầu tư tốn kém ngay từ đầu. Vậy doanh nghiệp của bạn có sẵn sàng đánh đổi không? Hay là bị khách hàng dí deadline, nên code sao cũng được?
Nói đến đây thôi, mình xin kết thúc phần 2 ở đây. Bài này sẽ còn phần 3 nữa, trong phần 3 chủ yếu mình sẽ chia sẻ kinh nghiệm trong việc như thiết kế use case trong các dự án lớn, hoặc giao tiếp với các service khác (như micro service chẳng hạn), ....
Hẹn gặp lại các bạn!