Mô hình hiện tại:
Bài viết trước đã cho thấy một số khó khăn, rủi ro tiềm ẩn nếu chúng ta muốn duy trì danh sách ProductId bên trong Brand. Bài viết này là một lựa chọn khác khi mối quan hệ chỉ là một chiều: Chỉ Product mới cần biết nó thuộc về Brand nào, còn Brand thì không. Hình 1 đã thể hiện rõ điều này khi trong Brand không có ProductId.
Giờ đây, tạo mới Product sẽ chỉ cần trải qua một transaction duy nhất:
// Code tại Application Use Case@Overridepublic void execute(String title, String brandCode) { Brand brand = this.brandRepository.findByCode(brandCode); if (brand == null) { throw new RuntimeException("Brand not found"); }
ProductIdGenerator productIdGenerator = new ProductIdGenerator( new IntegerRandomGenerator() ); ProductId id = productIdGenerator.generate(); Product product = new Product(id, title, brand); publish(productCreatedEvent); // Giả sử code như thế này
this.productRepository.save(product);}
Như đã nêu ra trong bài viết trước, sử dụng đối tượng Brand vào Product constructor là một ngầm định, hoặc một rủi ro tùy theo ý định của người thiết kế: Product product = new Product(id, title, brand); Ngầm định: Brand và Product có mối liên hệ chặt chẽ, ta không tách và cũng không nên tách chúng ra khỏi nhau. Rủi ro: Ngược lại, sự phụ thuộc chặt chẽ này sẽ khiến ta cần refactor nếu chúng cần phải tách biệt khi có nhu cầu, khi đó code sẽ có dạng: Product product = new Product(id, title, brandCode);. Hãy lựa chọn theo ý định của mình. Đôi khi, nghĩ quá xa cũng không phải là điều tốt. Hãy áp dụng YAGNI khi có thể nhé.
Dưới database, có thể sử dụng linh hoạt ba mô hình cho RD: Option 1, Option 2, và Option 3. Việc duy trì ràng buộc Foreign Key giữa BRANDS và bảng chứa BRAND_ID cần xem xét về mức độ nhất quán dữ liệu cần đảm bao cao hay không. Điều này chúng ta sẽ bàn trong một bài viết khác.
Đối với DD, document Brands không còn chứa thuộc tính danh sách ProductId nữa:
Các use case đã đi qua như tạo mới/chỉnh sửa Brand/Product là khá cơ bản với thiết kế này khi mọi thứ chạy là gần như hoàn hảo. Hãy đi tới một số use case khó hơn.
Liệt kê danh sách Product theo Brand
Yêu cầu 1
Tại màn hình xem chi tiết một Brand, Admin bấm vào "Danh sách Product", ứng dụng điều hướng tới màn hình "Danh sách Product" và liệt kê danh sách Product có phân trang.
Xem lại hình 1, yêu cầu này được xử lý khá dễ dàng với model hiện tại: Truy vấn Product với điều kiện BrandId.
Dù với Data Model nào đã lựa chọn trong 4 options, cũng đều cần thêm một cài đặt vật lý vào đó: Một Index trên BrandId.
Giờ ta đi vào chi tiết hơn cách thiết kế một chức năng tìm kiếm. Mặc dù hình 3 cho thấy chức năng liệt kê Product theo BrandId nhưng không hạn chế, chúng ta sẽ phân tích tổng quát hơn.
Như đã biết qua series ngắn gọn về DDD, việc tìm kiếm được ủy thác chủ yếu qua Repositories - nơi cài đặt các thao tác giao tiếp với nơi lưu trữ Aggregates.
Repository pattern
Sử dụng Repository là phổ biến nhất. Với Product hiện có, ta có ít nhất một số thông tin có thể làm tiêu chí tìm kiếm:
- Tên Product
- Mã Product
- Mã Brand
- Trạng thái Product
- Ngày hiệu lực
Và còn nhiều tiêu chí có thể trong tương lai.
Nếu chức năng tìm kiếm chỉ bao gồm "tìm theo BrandId" như hình 3, method cần đặt vào ProductRepository là: findByBrandId(BrandId) là một lựa chọn tối ưu: Rất rõ nghĩa và trực diện. Một chức năng tìm kiếm khác có thể theo tên Product: findByName(String) cũng có thể được thêm vào một cách khá thoải mái.
public interface ProductRepository { List findByBrandId(Long brandId); List findByName(String name);}
Giờ chức năng mới đòi hỏi kết hợp cả hai tiêu chí này trong cùng một hành động, ta bắt đầu thấy gượng gạo khi thêm vào method: findByNameAndBrandId(String, BrandId). Người dùng sẽ không khiến chúng ta thất vọng, một yêu cầu mới: "Tìm theo cả trạng thái", rồi "Tìm theo tên và trạng thái", "Tìm theo tên và BrandId và trạng thái", ... Cứ như thế, các phương thức tìm kiếm dần thêm phong phú.
public interface ProductRepository { // ... List findByStatus(ProductStatus status); List findByNameAndStatus(String name, ProductStatus status); List findByBrandIdAndStatus(BrandId brandId, ProductStatus status); List findByNameAndBrandIdAndStatus(String name, BrandId brandId, ProductStatus status); // Cứ thế tiếp diễn...}
Đây chính là vấn đề bùng nổ phương thức, Repository trở nên cồng kềnh, khó bảo trì, và vi phạm nguyên tắc OCP khi mỗi lần thêm một tiêu chí tìm kiếm mới, ta lại phải sửa đổi interface này.
Code sử dụng tại tầng Application:
public class ProductSearchUc { ... public List searchProducts(String name, BrandId brandId, ProductStatus status) {
boolean hasName = (name != null && !name.isEmpty()); boolean hasBrand = (brandId != null); boolean hasStatus = (status != null);
// Tìm phương thức tìm kiếm phù hợp if (hasName && hasBrand && hasStatus) { return productRepository.findByNameAndBrandIdAndStatus(name, brandId, status); } else if (hasName && hasBrand) { return productRepository.findByNameAndBrandId(name, brandId); } else if (hasName && hasStatus) { return productRepository.findByNameAndStatus(name, status); } else if ... }}
Client không chỉ bị bó buộc với hợp đồng mà chúng ta đưa ra khiến họ không có lựa chọn nào khác mà còn bị đóng vai trò lắp ghép truy vấn tìm kiếm. Một cách khắc phục thường gặp là tạo ra một phương thức "ôm đồm" ngay ban đầu mong sao có thể bao phủ được mọi tình huống tìm kiếm:
public List searchProducts(String name, BrandId brandId, ProductStatus status) { return productRepository.findAll(name, brandId, status);}
Interface giờ đây đã trở nên gọn gàng hơn khiến Client không phải lắp ghép nữa, sự phức tạp đó sẽ được chuyển vào một Repository Implementation, tại đó là một loạt câu lệnh điều khiển để xây dựng SQL mong muốn. Vấn đề không biến mất mà chỉ chuyển từ chỗ này sang chỗ kia:
// ProductRepositoryImplpublic List findAll(String name, BrandId brandId, ...) { String jpql = "SELECT p FROM Product p WHERE 1=1 "; if (name != null) { jpql += " AND p.name LIKE :name"; } if (brandId != null) { jpql += " AND p.brandId = :brandId"; } // ...}
Đây là những vấn đề thường gặp với cách code phổ biến hiện tại. Nếu đã từng kinh qua thời kỳ những năm 2000 - trước khi JPA phổ biến, ta còn có thể gặp phải vấn đề khác như: Chỉ định SQL ngay trong tầng Application hoặc Domain khiến Database Schema bị gắn chặt vào logic nghiệp vụ. Nhưng nếu áp dụng tương đối kiến trúc Hexagonal, điều này gần như không xảy ra nên chúng ta không bàn ở đây.
Với những vấn đề như vậy, ta cần một cách tiếp cận tốt hơn: Query Object Pattern.
Query Object pattern
Query Object Pattern (QOP) giúp thiết kế một interface tìm kiếm gọn gàng đồng thời cung cấp một cách có cấu trúc, hướng đối tượng để xây dựng logic truy vấn thay vì các khối if/else khổng lồ.
Code Application:
public class ProductSearchUc { public List searchProducts(String name, BrandId brandId, ProductStatus status) { ProductQuery query = new ProductQuery(name, null, brandId, status, null); return productRepository.findAll(query); }}
Giờ đây, Client chỉ cần tạo ra query rồi gửi vào ProductRepository mà không cần quan tâm phương thức truy vấn là gì. Việc sử dụng đã rõ ràng hơn rất nhiều. Nhưng các tham số "null" vẫn có phần "xấu xí", ta cải tiến nó với Builder Pattern:
public List searchProducts(String name, Long brandId, ProductStatus status) { ProductQuery query = new ProductQuery.Builder() .nameContains(name) .brandId(brandId) .status(status) .build(); return productRepository.findAll(query);}
Lớp Application lúc này gần như hoàn hảo khi builder pattern cho thấy rõ ý nghĩa của xây dựng query và các tham số null được xử lý bên trong. Tính đóng gói đã được nâng thêm một bước nữa. Nhờ đó, đảm bảo DRY khi các khối if/else cũng không xuất hiện ở nhiều nơi, logic định nghĩa một lần và tái sử dụng ở bất kỳ đâu trong dịch vụ.
Một lợi thế tuyệt vời nữa mà QOP mang lại là khả năng tự xây dựng query theo ý của client thay vì sử dụng phương thức truy vấn mà Repository mang tới.
ProductQuery có cấu trúc:
Một Query Object như ProductQuery có các tiêu chí truy vấn Criteria và một Builder bên trong (một tùy chọn).
Đây là code của ProductQuery ở phiên bản Builder Pattern:
public final class ProductQuery {
// Các thuộc tính đều là `final` để đảm bảo tính bất biến private final String nameContains; private final String codeEquals; private final Long brandId; ...
/** * Constructor là private, chỉ có lớp Builder mới có quyền gọi. */ private ProductQuery(Builder builder) { this.nameContains = builder.nameContains; this.codeEquals = builder.codeEquals; ... }
// --- Getters --- // Sử dụng Optional để xử lý các giá trị có thể null một cách an toàn. public Optional getNameContains() { return Optional.ofNullable(nameContains); } public Optional getCodeEquals() { return Optional.ofNullable(codeEquals); } public Optional getBrandId() { return Optional.ofNullable(brandId); } ... public static class Builder { private String nameContains; private String codeEquals; private Long brandId; ... public Builder nameContains(String name) { if (name != null && !name.trim().isEmpty()) { this.nameContains = name; } return this; }
public Builder codeEquals(String code) { if (code != null && !code.trim().isEmpty()) { this.codeEquals = code; } return this; }
public ProductQuery build() { return new ProductQuery(this); } ... }}
ProductQuery mới chỉ như một yêu cầu truy vấn. Để truy vấn hoạt động được, ta cần thêm các thành phần sau:
- Một bộ thông dịch: Dịch yêu cầu sang đối tượng mà framework có thể hiểu được.
- Một Output Adapter: Đây là adapter mà ta vẫn thường gặp, là một cài đặt cụ thể của Output Port (ProductRepository), gọi tới công cụ Data Access cụ thể là Spring Data JPA.
- Một hoặc nhiều Spring Data Jpa Repository: Làm việc trực tiếp với database.
Tầng Domain:
Tầng Domain ngoài Product và ProductRepository là hai building block chính của DDD mà chúng ta đã biết thì giờ đây có thêm ProductQuery. Điều này có được vì ProductRepository phụ thuộc trực tiếp vào ProductQuery qua giao diện hàm findAll(ProductQuery), mặt khác, tuy ProductQuery không chứa Invariant nào cả nhưng nó đóng gói và mô tả một khái niệm hoặc một ý định nghiệp vụ: ProductQuery là một công cụ giao tiếp (yêu cầu tìm kiếm) được định nghĩa bằng ngôn ngữ miền (Ubiquitous Language).
Application layer sử dụng các đối tượng này tạo nên sơ đồ phụ thuộc sau:
Tới đây, cỗ máy tìm kiếm vẫn chưa hoạt động được vì vẫn còn thiếu các thành phần cụ thể:
- Thành phần dịch ngôn ngữ nghiệp vụ trong ProductQuery thành ngôn ngữ kỹ thuật truy vấn database.
- Thành phần Output Adapter như thường làm.
Theo hình 7, cả ba layer đã hình thành với những nhiệm vụ cụ thể. Trong đó, tầng Adapter chứa:
Một Output Adapter ProductRepositoryImpl
- Trong kiến trúc Hexagonal class này cần realize ProductRepository trong Domain layer để triển khai cụ thể cách thức truy vấn.
- ProductRepositoryImpl có thuộc tính ProductRepositoryJpa. Đây sẽ là thành phần giúp thực thi DIP bằng cơ chế DI.
- ProductRepositoryImpl phụ thuộc (depend) vào ProductSpecificationFactory và Specification (của JPA).
Code thể hiện sơ đồ trong hình 7 như sau:
public class ProductRepositoryImpl implements ProductRepository {
private final ProductRepositoryJpa productRepoJpa;
public ProductRepositoryImpl(ProductRepositoryJpa productRepoJpa) { this.productRepoJpa = productRepoJpa; }
@Override public List findAll(ProductQuery query) { // Bước 1: Gọi "Người dịch" để chuyển đổi ProductQuery thành Specification. Specification spec = ProductSpecificationFactory.from(query); // Bước 2: Ủy quyền việc thực thi cho công cụ của Spring Data JPA. return this.productRepoJpa.findAll(spec); } ...}
Như vậy, ProductSpecificationFactory làm nhiệm vụ phiên dịch ngôn ngữ truy vấn nghiệp vụ thành ngôn ngữ truy vấn kỹ thuật. Bên trong, cơ bản nó sẽ chứa những khối if/else mà ta đã đề cập bên trên:
public class ProductSpecificationFactory { public static Specification from(ProductQuery query) { // Trả về một lambda triển khai interface Specification return (root, cq, cb) -> { List predicates = new ArrayList<>(); // 1. Dịch từng tiêu chí từ ProductQuery thành Predicate query.getNameContains().ifPresent(name -> predicates.add(cb.like(root.get("name"), "%" + name + "%"))); query.getBrandId().ifPresent(brandId -> predicates.add(cb.equal(root.get("brandId"), brandId))); ... return cb.and(predicates.toArray(new Predicate[0])); }; }}
Tới đây, có thể thấy rằng 3 đối tượng ProductSpecificationFactory, Specification (JPA), và ProductRepositoryJpa là những đối tượng đặc thù theo cơ chế truy vấn database. Nói cách khác, nếu ta sử dụng cơ chế truy vấn khác, ta sẽ cần cài đặt lại 3 đối tượng này. Hãy thử xem nếu sử dụng JdbcTemplate, ta sẽ cần
- Một đối tượng phiên dịch tương tự như ProductSpecificationFactory. Đối tượng này tiếp nhận ProductQuery nhưng không trả về Jpa Specification mà trả về một SqlQuery - một POJO đơn giản chứa SQL cần thực thi. Ta đặt tên đối tượng này là ProductSqlQueryFactory.
- Đối tượng SqlQuery tương tự như Specification nhưng chứa câu lệnh SQL cùng tham số phiên dịch.
- JdbcTemplate hoặc tự viết.
Việc tối ưu cách thay thế cơ chế truy vấn giữa JPA và JdbcTemplate hoặc tự xây dựng có lẽ không cần thiết.
Hãy xem qua một số dòng code thể hiện hình 8:
SqlQuery - đối tượng thay thể Jpa Specification:
public class SqlQuery { private final String sql; private final List<Object> params;
public SqlQuery(String sql, List<Object> params) { this.sql = sql; this.params = params; }
public String getSql() { return sql; } public Object[] getParamsArray() { return params.toArray(); }}
SqlQuery chứa cả câu lệnh truy vấn SQL và params cụ thể được truyền vào trong Output Adapter.
Đối tượng phiên dịch yêu cầu nghiệp vụ sang yêu cầu kỹ thuật:
public class ProductSqlQueryFactory { public static SqlQuery from(ProductQuery query) { StringBuilder sql = new StringBuilder("SELECT * FROM products WHERE 1=1"); List<Object> params = new ArrayList<>();
query.getNameContains().ifPresent(name -> { sql.append(" AND name LIKE ?"); params.add("%" + name + "%"); });
query.getBrandId().ifPresent(brandId -> { sql.append(" AND brand_id = ?"); params.add(brandId); }); ... return new SqlQuery(sql.toString(), params); }}
Đội tượng này vẫn cần tiếp nhận ProductQuery nhưng thay vì trả vể Specification, nó trả về SqlQuery.
Và cuối cùng, Output Adapter đảm nhận logic điều phối truy vấn:
public class ProductRepositoryImpl implements ProductRepository {
private final JdbcTemplate jdbcTemplate;
public ProductRepositoryImpl(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; }
@Override public List<Product> findAll(ProductQuery query) { // Bước 1: Gọi "Người dịch" để chuyển đổi ProductQuery thành SqlQuery. SqlQuery sqlQuery = ProductSqlQueryFactory.from(query); // Bước 2: Ủy quyền việc thực thi cho JdbcTemplate. return this.jdbcTemplate.query( sqlQuery.getSql(), new ProductRowMapper(), sqlQuery.getParamsArray() ); } ...}
Tới đây có thể bạn sẽ thắc mắc rằng vấn đề ta đặt ra ở mục trước về việc di chuyển các khối if/else từ nơi này sang nơi khác với lựa chọn nơi đó là ProductRepositoryImpl không tối ưu thì giờ các khối đó đang nằm trong các class Factory thì sao lại là tối ưu hơn? Đó là vì ta đã phân tách trách nhiệm rõ ràng. Theo lựa chọn trước, ProductRepositoryImpl đang làm cả hai việc: Phiên dịch và điều phối truy vấn, còn giờ, nó ủy quyền phiên dịch sang class Factory và chỉ tập trung vào điều phối mà thôi.
