Entity, domain model và DTO – sao nhiều quá vậy?
Bài viết ngày hôm nay rất hay và đề cập đến một chủ đề quan trọng trong Spring Boot. Chúng ta sẽ tìm hiểu cách dữ liệu thay đổi khi đi qua các lớp khác nhau và các khái niệm như Entity, Domain model và DTO là gì.
1. Tổng quan về kiến trúc của Spring Boot
1.1. Kiến trúc source code và kiến trúc dữ liệu
Trong các phần trước, chúng ta đã biết rằng mọi ứng dụng Spring Boot đều tuân theo hai mô hình cơ bản:
- Mô hình MVC
- Mô hình 3 lớp (3 tier)
Do đó, chúng ta có thể tổ chức ứng dụng thành thành phần như sau.
Sơ đồ trên được sử dụng để tổ chức source code trong chương trình. Chúng ta chia thành các Controller, Service, Repository tương ứng với các lớp. Tuy nhiên, khi xét về tổ chức dữ liệu, sơ đồ sẽ trở thành như sau.
Mô hình này cũng bao gồm 3 lớp, trong đó tên các lớp tương ứng đã được đổi thành các thành phần tương ứng trong Spring Boot.
Theo đó, với mỗi lớp, các dữ liệu sẽ có dạng khác nhau. Nghĩa là mỗi lớp chỉ nên xử lý một số loại dữ liệu nhất định. Mỗi dạng dữ liệu sẽ có nhiệm vụ và mục đích khác nhau. Và tất nhiên, trong code cũng được chia ra tương ứng.
Ví dụ, trong sơ đồ, Controller không nên truy cập vào các dữ liệu dạng domain model hoặc entity, mà chỉ được nhận và trả về dữ liệu dạng DTO.
1.2. Vì sao phải chia thành nhiều dạng dữ liệu
Điều này là do việc tuân thủ nguyên tắc SoC – separation of concerns (tách biệt các mối quan tâm) trong thiết kế phần mềm. Cụ thể, chúng ta đã tách nhỏ ứng dụng Spring Boot thành các phần như sau.
Spring Boot = Presentation layer (giao diện) + Service layer (lớp dịch vụ) + Data access layer (lớp truy cập dữ liệu)
Đó là việc chia nhỏ source code theo nguyên tắc SoC. Tuy nhiên, ở mức thấp hơn, SoC cũng được thể hiện thông qua nguyên tắc SOLID đầu tiên (nguyên tắc đơn nhiệm). Điều này có nghĩa là mỗi lớp chỉ nên thực hiện một nhiệm vụ duy nhất.
Do đó, dữ liệu chỉ có một dạng trước đây, nhưng có nhiều lớp và mỗi lớp xử lý dữ liệu khác nhau, dữ liệu đã phục vụ nhiều nhiệm vụ. Điều này vi phạm nguyên tắc đơn nhiệm, nên chúng ta cần chia thành nhiều dạng dữ liệu khác nhau.
Một nguyên nhân khác là nếu dữ liệu chỉ có một dạng, dữ liệu nhạy cảm có thể bị lộ. Ví dụ, trong chức năng tìm kiếm bạn bè trên Facebook, thực ra chỉ cần trả về các thông tin cơ bản (ảnh đại diện, tên,…). Tuy nhiên, nếu chỉ có một dạng dữ liệu, toàn bộ thông tin sẽ được trả về. Dù client chỉ hiển thị những thông tin cần thiết, việc trả về tất cả thông tin này có thể được lợi dụng để lấy thông tin nhạy cảm.
Vì vậy, việc tách riêng các dạng dữ liệu cũng là một cách để tăng cường bảo mật cho ứng dụng.
2. Các dạng dữ liệu
2.1. Hai loại dữ liệu
Theo sơ đồ trên, dữ liệu trong ứng dụng Spring Boot được chia thành hai loại chính:
- Public: nghĩa là dữ liệu dùng để trao đổi, chia sẻ với bên ngoài qua REST API hoặc giao tiếp với các dịch vụ khác trong mô hình microservice. Dữ liệu ở đây được đại diện bởi các DTO (Data Transfer Object).
- Private: các dữ liệu dùng trong nội bộ ứng dụng, không được tiết lộ với bên ngoài. Dữ liệu ở đây có thể thuộc các domain model hoặc entity.
Có nhiều cách gọi dữ liệu nhưng chúng đều thuộc hai nhóm trên. Do đó, khi áp dụng kiến trúc Spring Boot, chúng ta cần xem xét xem loại dữ liệu nào phù hợp với mỗi lớp (xem phần 2.2).
Từ hai loại dữ liệu public và private trên, chúng ta có ba dạng dữ liệu:
- DTO (Data Transfer Object): là các lớp đóng gói dữ liệu để chuyển đổi giữa client – server hoặc giữa các dịch vụ trong mô hình microservice. Mục đích của việc tạo ra DTO là giảm lượng thông tin không cần thiết phải chuyển đi và tăng cường bảo mật.
- Domain model: là các lớp đại diện cho các domain, tức là các đối tượng thuộc lĩnh vực kinh doanh như Client, Report, Department,… Ví dụ, trong ứng dụng thực tế, các lớp đại diện cho kết quả tính toán, các lớp làm tham số đầu vào cho dịch vụ tính toán, được coi là domain model.
- Entity: cũng là domain model nhưng tương ứng với bảng trong cơ sở dữ liệu, có thể ánh xạ vào cơ sở dữ liệu. Lưu ý rằng chỉ có entity mới có thể đại diện cho dữ liệu trong cơ sở dữ liệu.
Các dạng dữ liệu này thường có hậu tố tương ứng, trừ entity. Ví dụ, nếu là domain model, lớp đại diện là UserModel, còn với DTO, chúng ta có thể sử dụng UserDto,…
2.2. Nguyên tắc chọn dữ liệu tương ứng với lớp
Quả thật, việc gọi nó như thế nào mình không biết. Tóm lại, từng lớp trong mô hình 3 lớp sẽ thực hiện xử lý, nhận và trả về dữ liệu thuộc các loại cụ thể.
Áp dụng vào mô hình 3 lớp trong sơ đồ, chúng ta có thể rút ra nguyên tắc thiết kế chung:
- Web layer: chỉ nên xử lý DTO, tức là các Controller chỉ nhận và trả về dữ liệu dưới dạng DTO.
- Service layer: nhận vào DTO (từ controller gửi qua) hoặc Domain model (từ các dịch vụ nội bộ khác). Dữ liệu được xử lý (có thể tương tác với cơ sở dữ liệu) và cuối cùng được Service trả về dưới dạng DTO cho Web layer.
- Repository layer: chỉ thao tác trên Entity, vì đó là đối tượng phù hợp, có thể ánh xạ vào cơ sở dữ liệu.
Đối với các thành phần khác của Spring Boot không thuộc một lớp nào đó, thì:
- Custom Repository: đây là một lớp không thông qua repository mà truy cập trực tiếp vào cơ sở dữ liệu. Do đó, lớp này được xem xét như một Service.
2.3. Model mapping
Khi dữ liệu đi qua các lớp khác nhau, nó sẽ được chuyển đổi thành các dạng khác nhau. Ví dụ, nếu DTO được gửi từ controller vào service, nó sẽ được chuyển đổi thành domain model hoặc entity, sau đó khi đến Repository, nó buộc phải trở thành Entity. Và ngược lại cũng đúng.
Quá trình chuyển đổi giữa các dạng dữ liệu như DTO và Entity, DTO và domain model hoặc ngược lại được gọi là model mapping.
Thường thì việc thực hiện model mapping được thực hiện bằng cách sử dụng các thư viện như ModelMapper (cách sử dụng sẽ được đề cập trong bài viết tiếp theo). Tuy nhiên, nếu muốn đơn giản, chúng ta có thể sử dụng cách sao chép thuần túy như sau.
“`java
@Getter
public class UserDto {
String name;
String age;
public void loadFromEntity(User entity) {
this.name = entity.getName();
this.age = entity.getAge();
}
}
@Getter
public class User {
String name;
String age;
String crush;
public void loadFromDto(UserDto dto) {
this.name = dto.getName();
this.age = dto.getAge();
}
}
“`
Khi sử dụng, phần code trông tương tự như sau.
“`java
// Trong controller, chuyển từ DTO > entity
User user = new User();
user.loadFromDto(userDto);
// Hoặc, chuyển đổi ngược lại, từ Entity > DTO
User user = userService.getUser(username);
UserDto userDto = new UserDto();
userDto.loadFromEntity(user);
return userDto;
“`
Một cách đơn giản hơn là chúng ta có thể sao chép từ các constructor. Điều này làm giảm đoạn mã chép nhạt hơn.
“`java
User user = new User(userDto); // DTO > entity
UserDto userDto = new UserDto(user); // Entity > DTO
“`
3. Thực tế như thế nào?
Khi được áp dụng trong thực tế, có vô số các trường hợp khác nhau xảy ra. Nó không chỉ đơn giản theo mô hình sau.
Controller nhận DTO > Service chuyển đổi DTO thành model hoặc entity, sau đó xử lý > Repository nhận Entity rồi lưu vào cơ sở dữ liệu
Repository lấy dữ liệu từ cơ sở dữ liệu ra và trả về Entity > Service xử lý rồi trả về DTO > Controller và trả về DTO
Ngoài ra, còn các trường hợp khác như:
- Controller không nhận DTO mà nhận các tham số nguyên thủy như int, float,…
- Nhận một danh sách DTO
- Trả về một danh sách DTO
- …
Do đó, trong thực tế, người ta có thể thay đổi để phù hợp với dự án cụ thể.
Ví dụ, điển hình là Service sẽ thực hiện việc chuyển đổi thành DTO và ngược lại, trong khi controller chỉ nhận DTO. Tuy nhiên, trong một số trường hợp, việc chuyển đổi này có thể do controller thực hiện để giảm tải cho service (mặc dù việc này có thể làm cho controller lớn hơn, trong khi theo nguyên tắc, controller nên rất mỏng – có ít code hơn càng tốt).
Dù có cách nào đi nữa, quy tắc chung là việc chuyển đổi dữ liệu luôn được thực hiện ở rìa của code. Điều này có nghĩa là nếu chuyển đổi được thực hiện trong service, quá trình chuyển đổi phải nằm ở đầu hoặc cuối của một phương thức khi nó được xử lý.
Loài ra, để giảm đoạn mã lặp lại, chúng ta thường giảm sự cứng nhắc nếu không cần thiết. Ví dụ:
- Đôi khi, không cần domain model, Service có thể chuyển DTO trực tiếp thành entity.
- Service cũng có thể trả về Entity hoặc Model, nếu chúng quá đơn giản và không chứa thông tin nhạy cảm. Trong trường hợp này, không cần DTO, mà controller có thể trả về Entity hoặc Model trực tiếp để tránh gây rối (mặc dù là vi phạm nguyên tắc khi xuất hiện hai thành phần này, nhưng ta nên cân nhắc).
Việc sử dụng DTO cũng có nhiều ý kiến tranh cãi, có người coi đó là một anti-pattern. Riêng cá nhân tôi không nhìn nhận thế, vì có nhiều trường hợp mà DTO vẫn hữu ích và có thể tùy biến để phù hợp và hiệu quả hơn.
Bài viết đã kết thúc. Thật tốn công viết, vì phải nói về nhiều khía cạnh kiến trúc. Hôm qua, tôi đã cảm thấy cần phải sửa lại chuẩn kiến trúc trong một dự án, để hiểu rõ hơn về kiến trúc mà tôi sẽ trình bày và các tác động phụ có thể xảy ra.
Trong bài viết này, tôi đã tham khảo từ nguồn https://www.petrikainulainen.net/software-development/design/understanding-spring-web-application-architecture-the-classic-way/ mà tôi thấy hay nhất. Trang trên còn có phần kết và nhận xét, bạn có thể đọc thêm.
Nếu bạn cảm thấy bài viết hay và hữu ích, hãy ủng hộ và động viên tôi bằng cách upvote và clip. Tạm biệt