logoMột hệ thống khó hiểu thì cũng khó thay đổi
Catalog - Thiết kế Product: ProductId

(Lại) Tản mạn

Ngày còn bé, thỉnh thoảng tôi được hỏi rằng: “Con muốn làm gì khi lớn lên?” Không biết những đứa trẻ khác cùng trang lứa trả lời thế nào chứ với tôi thì tôi không biết. Có lẽ không phải bởi tôi không chọn được gì mà vì mình không có đủ thông tin. Có lẽ cũng do mình thực tế nên mình không muốn trở thành siêu nhân hay siêu anh hùng gì cả. Khi lớn hơn, học đại học, dù học ở trường “danh giá” thời đó, tôi vẫn không biết mình sẽ làm gì cho cao siêu, chỉ nghĩ rằng học xong sẽ đi làm, làm đúng nghề mà mình đang học là được rồi. Mình quả thật không có mục đích sống rõ ràng, hoặc quá tầm thường. Tới khi đi làm, tôi tiếp xúc với J2EE và dần có mong muốn sau này trở thành một kiến trúc sư trưởng của một hệ thống thật hoành tráng, chỉ cần làm trong đời một lần thôi cũng được. Nhưng thật đâu có giống mơ. Mơ mộng là tốt nhưng nếu không có cơ sở trong tay, sẽ không thể biến giấc mơ đó thành hiện thực.

Khi bạn đọc những dòng chữ này hay các bài viết trên blog của tôi, hẳn bạn cũng đang trên cùng con đường mà tôi hay chúng ta đã chọn. Hãy cùng xây dựng một cơ sở nền tảng vững chắc để có thể biến mục tiêu của bạn thành hiện thực.

Một hệ thống phần mềm hoành tráng, ví dụ như hệ thống logistics của Amazon, hệ thống phần mềm đồ sộ của Google, hay của bất kỳ công ty nào, luôn bắt đầu từ những chức năng cơ bản nhất. Ở đó, các kỹ sư có thể thử nghiệm linh hoạt giải pháp của mình và có thể loại bỏ nó ngay nếu nó không mang lại hiệu quả kinh doanh. Bởi nó nhỏ.

Hãy tưởng tượng, chúng ta đang làm việc trong một công ty startup muốn triển khai ứng dụng nền tảng marketplace cho người bán và người mua cùng tham gia, giống như Shopee vậy. Chúng ta sẽ bước vào giai đoạn thử nghiệm ý tưởng với những chức năng đơn giản và phù hợp với mục tiêu “xây dựng cơ sở” phát triển phần mềm. Và biết đâu, các nội dung trong này không chỉ giúp cho các bạn rèn luyện kỹ năng thiết kế nói chung, mà còn có thể giúp ai đó có được ý tưởng cho một nền tảng thực tế trong tương lai.

Nhắc lại một ý về kiến trúc

Trước khi đi vào thiết kế, ta sẽ bàn về một số quan điểm mà Clean Architecture (Uncle Bob) nêu ra. 

Hãy tưởng tượng rằng bạn đang xem bản thiết kế của một tòa nhà. Bản vẽ này cho bạn biết điều gì? Nếu bản vẽ cho thấy lối vào phía trước, tiền sảnh dẫn đến phòng khách và sau đó có thể là phòng ăn. Có thể sẽ có một nhà bếp cách đó một quãng ngắn, gần phòng ăn, … Khi nhìn vào đó, chắc chắn bạn đang xem xét một ngôi nhà riêng, dành cho một gia đình. Bản thiết kế đó đã hết lên: “NHÀ RIÊNG”. Giờ giả sử bạn thấy một lối vào lớn, một khu vực dành cho nhân viên nhận/trả sách, khu vực đọc sách, phòng họp nhỏ và các phòng trưng bày liền kề nhau có khả năng chứa các giá sách cho tất cả sách trong thư viện. Kiến trúc này hét lên: “THƯ VIỆN”.

Kiến trúc phần mềm cũng vậy, và nên là như vậy. Kiến trúc không nên đề cập tới framework, và cũng không nên được hỗ trợ bởi framework. Framework là các công cụ được sử dụng chứ không phải là thứ mà kiến trúc phải tuân theo. Nếu kiến trúc của bạn dựa trên framework thì nó không thể dựa trên các use case của bạn.

Một kiến trúc tốt sẽ tập trung vào use case để qua đó có thể mô tả một cách an toàn các cấu trúc hỗ trợ các use case đó mà không cần phải cam kết với các framework, công cụ, và môi trường. Một lần nữa, hãy xem xét bản vẽ của một ngôi nhà. Mối quan tâm đầu tiên của kiến trúc sư là đảm bảo rằng ngôi nhà có thể sử dụng được chứ không phải đảm bảo rằng ngôi nhà được làm bằng gạch. Thật vậy, kiến trúc sư miệt mài để đảm bảo rằng sau này chủ nhà có thể đưa ra quyết định về vật liệu bên ngoài (gạch, đá hoặc gỗ tuyết tùng), sau khi bản thiết kế đảm bảo rằng các trường hợp sử dụng được đáp ứng.

Một kiến trúc phần mềm tốt cho phép những quyết định về framwork, database, web server, và những vấn đề liên quan tới môi trường và công cụ có thể được trì hoãn. Framework là những sự lựa chọn cần được bỏ ngỏ. Một kiến trúc tốt là làm cho những quyết định về Rails, Spring, hay Hibernate, Tomcat, MySQL là không cần thiết cho tới rất lâu sau này trong dự án. Một kiến trúc tốt cũng giúp bạn có thể dễ dàng thay đổi những quyết định như vậy. Một kiến trúc tốt nhấn mạnh vào các use case và tách chúng ra khỏi những mối quan tâm ngoại vi.

Kiến trúc phải hỗ trợ chức năng

Các use case của ứng dụng cần được hiển thị rõ ràng trong kiến trúc hệ thống. Các lập trình viên không phải cố gắng tìm kiếm các hành vi bởi chúng là những thành phần thuộc lớp đầu tiên có thể thấy được ở cấp độ hệ thống. Các thành phần này là những class, những function, hay những module có vị trí nổi bật trong kiến trúc, và chúng sẽ được mang những tên gọi mô tả rõ ràng chức năng của chúng.

Xác định ranh giới kiến trúc

Theo đó, các use case hình thành ranh giới giữa các thành phần trong kiến trúc một cách tự nhiên.

Use case là thành phần trung gian giữa người dùng và thực thể Entity. Trong use case không quy định về giao diện người dùng nên là gì (web, app, console), nó chỉ mô tả các quy tắc đặc tả ứng dụng. Chính vì thế, use case là một đối tượng đại diện cho ứng dụng, và ở cấp thấp hơn so với thực thể.

Theo danh sách use case hiện có, ta có thể xác định được một cách khá cơ bản các module chính của ứng dụng: Catalog, Listings, Inventory, Promotion, Product Discovery, Order Management, và Payment.

Hình 1: Các nhóm use case tự nhiên hình thành các module chính của hệ thống

Đây là phiên bản đầu tiên của kiến trúc cho ứng dụng mà ta đang hướng tới. Theo bạn, nó có “hét” lên điều gì không? Có lẽ là có.

Thứ mà có lẽ bạn khá băn khoăn là bức tranh này không có các đường kết nối, bởi vì:

“Kiến trúc hệ thống là việc phân chia một ứng dụng thành các thành phần nhỏ hơn và sắp xếp chúng sao cho chúng có thể phối hợp với nhau để hoàn thành các yêu cầu …”

Để hoàn thiện bức tranh này ta sẽ cần đi qua nhiều bước. Nhưng trước hết, hãy khởi động với Catalog.

Chú ý rằng, bức tranh trên được tạo ra căn cứ chủ yếu trên danh sách use case đã được gom theo nhóm mà đội ngũ phân tích nghiệp vụ trước đó đã hoàn thành. Hiện nay, các dự án thường chạy theo quy trình Agile đòi hỏi nhanh theo từng chu kỳ nhỏ, chính vì thế, nhiều khi một tài liệu use case như vậy là thứ xa xỉ với kiến trúc sư. Một phương pháp hiệu quả để giải quyết vấn đề này là EventStorming: Các bên liên quan họp với nhau và cùng đóng góp hiểu biết của mình vào ứng dụng cần xây dựng. Xuất phát với các sự kiện xảy ra trong hệ thống (Event-driven), để xác định các Aggregate chính. Đánh giá xem Aggregate nào mang lại những thay đổi lớn (nói cách khác, chúng là Aggregate chính, có ngôn ngữ nghiệp vụ riêng), hình thành cho nó một bounded context riêng. Kết quả sẽ là sơ đồ các nhóm use case gần như y hệt bức hình bên trên.

Product

Các use case hiện tại của Catalog:

Vai trò: Quản lý hàng hóa chuẩn, không phụ thuộc vào Merchant.

  • Create Product / Edit Product (quản lý thông tin chung)
  • Add Variant to Product / Edit Variant (quản lý các Variant)
  • Manage Categories, Brands, Attributes (quản lý danh mục)
  • Update Base Price (cập nhật giá niêm yết, nếu có)
  • Publish/Retire Product (duyệt và cho hiển thị)

Hãy tập trung vào trọng tâm trước: Product. Các đối tượng khác sẽ được đề cập từ sự phân tích Product này.

Thuộc tính quan trọng của Product

Các thuộc tính trực tiếp cần để hoàn thành những use case ưu tiên.

Brand: Thương hiệu chính thức của sản phẩm (Apple, Nike, Sony). Brand là facet filter quan trọng khi khách hàng tìm kiếm, thường được chuẩn hóa theo brand whitelist để tránh việc Merchant nhập liệu không rõ ràng, thiếu nhất quán, sai lệch.

Model: Tên model cụ thể trong Brand (iPhone 17 Pro, Air Jordan 1). Cùng với Brand, Model chính là thông tin quan trọng để phân biệt sản phẩm, là cơ sở để Merchant ánh xạ vào dùng Product khi tạo Offer.

Category: Giúp phân loại sản phẩm (Electronics -> Mobile Phones -> Smartphones). Category định nghĩa ngữ cảnh của Product, giúp search hoạt động đúng (facet filter, breadcrumb). Invariant: Không thể publish Product mà chưa gán Category.

Slug: Chuỗi URL thân thiện với SEO, thường được tạo tự động từ Title.

Title: Tên hiển thị chuẩn trên PDP/Search (Apple iPhone 17 Pro). Title là thông tin đầu tiên khách hàng nhìn thấy, nếu thiếu title thì không thể hiển thị Product. Invariant: title != null khi publish. Hệ thống có thể tự động tạo Title gợi ý từ Brand + Model nhưng nên cho phép Admin tùy chỉnh để tối ưu hơn.

Description: Là phần mô tả chi tiết giúp khách hàng hiểu rõ sản phẩm. Description cung cấp nội dung marketing, tăng SEO, bắt buộc để PDP có đủ nội dung khi publish.

Images[]: Tập hợp bộ ảnh của sản phẩm. Marketplace thường yêu cầu tối thiểu một ảnh chất lượng cao. Invariant: images.length >= 1 khi publish. Ảnh ở cấp độ này nên là các ảnh chung, ảnh lifestyle, ảnh quảng cáo cho cả dòng sản phẩm (ví dụ: ảnh nhiều chiếc iPhone 17 Pro với đủ màu sắc).

Specifications: Bảng thông số kỹ thuật có cấu trúc để hiển thị và so sánh. Dùng JSON (JSONB). Ví dụ: {"cpu": "A20 Bionic", "ram": "12GB"}

Variant Attributes: Danh sách trục dùng để sinh Variant. Thuộc tính cho phép khách chọn biến thể đúng, nếu không định nghĩa, Product chỉ là khung rỗng. Đây chính là "khuôn mẫu" hay "định nghĩa các trục biến thể" (ví dụ: trục Màu sắc, trục Dung lượng). Điều này khác với các thuộc tính trên một Variant cụ thể (ví dụ: Màu sắc: Cosmic Orange).

Category-driven attributes: Khi Admin tạo một Product và gán nó vào category "Áo thun", giao diện người dùng sẽ tự động hiển thị các trường "Chất liệu", "Size", "Màu sắc" để nhập liệu dựa theo template đã được định nghĩa. Điều này giúp đảm bảo dữ liệu đầu vào luôn đầy đủ và có cấu trúc, phục vụ tốt cho việc lọc sản phẩm sau này.

  • Category xác định field nào bắt buộc.
  • Ví dụ: Điện thoại thì bắt buộc có brand, model; Quần áo thì bắt buộc có material, size.

Status: Chỉ vòng đời của Product trong Catalog:
•    Draft: Mới tạo, chưa có đủ thông tin.
•    Published: Đã duyệt, hiển thị cho khách hàng.
•    Retired: Ngừng kinh doanh, ẩn khỏi Discovery.

Từ các thông tin trên, ta có mô hình Product cơ bản như sau:

Hình 2: Thông tin xoay quanh Product

Nhìn vào nhóm thông tin khá dài này, trong đó có những thông tin khá thách thức để mô hình hóa, ta nên bình tĩnh. Quy trình mang tính kinh nghiệm là:

  • Xác định xem đối tượng là Entity hay Value Object.
  • Nếu là Entity, xác định Identity.
  • Xác định các thuộc tính quan trọng nhất sau Identity.
  • Xác định các hành vi quan trọng nhất để maintain các thuộc tính quan trọng nhất này.
  • Mở rộng ra các thuộc tính khác.

Product ID

id: Marketplace quyết định sử dụng id có dạng tiền tố “PRD” theo sau là một chuỗi số tự tăng có 7 chữ số: “PRD-0123456”.

Do id là định danh của Product nên bất kỳ hai Product nào có id trùng nhau đều được coi là một. Ta cần cài đặt cơ chế này:

public class Product {
    private String id;
    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        Product product = (Product) o;
        return Objects.equals(id, product.id);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }
}

Với cấu trúc “PRD-Dãy 7 số” có các điểm sau cần cân nhắc:

  • Làm thế nào để sinh ra chuỗi số này?
  • Sinh chuỗi số này hay sinh ra id ở đâu (bên ngoài hay bên trong Product)?

Cách sinh chuỗi số

Cách phổ biến nhất là sử dụng một ID tự tăng trong database. Nhưng cách này đòi hỏi cần insert dữ liệu mới có được. Một số database hỗ trợ sequence có thể triển khai được dễ dàng hơn. Để chuẩn theo DDD, ta sẽ sinh ID trước khi lưu xuống database. Do đó, ta cần một cơ chế sinh giá trị số này, đó sẽ là một Interface: NumberValueGenerator.

Interface này giúp cho ta có thể thay thế cách sinh khác nhau: Ngẫu nhiên, từ database sequence, hay theo cách tăng của database không hỗ trợ sequence.

Hình 3: Các cách sinh số khác nhau có thể cài đặt

NumberValueGenerator cần đảm bảo sinh được các loại số khác nhau như Integer, Short, Long nên cần thiết kế với Generic type:

public interface NumberValueGenerator {
    T execute(T origin, T bound);
}

Các tham số origin và bound quy định ranh giới min-max mà giá trị ngẫu nhiên cần thuộc về. Để thuận tiện cho demo, ta sẽ sử dụng IntegerRandomGenerator. Mỗi khi sử dụng, code như sau:

NumberValueGenerator numberValueGenerator = new IntegerRandomGenerator()
int intValue = numberValueGenerator.execute(0, 9999999);

Product ID có dạng tiền tố là “PRD-“ còn hậu tố là một chuỗi số 7 ký tự, nếu chuỗi số nhỏ hơn 9999999 thì mặc định thêm vào các số 0 phía trước, ví dụ “PRD-0000001”. Ta không nên để cho client tự làm các việc này, mà hãy đóng gói lại toàn bộ những yêu cầu này giúp họ dễ sử dụng thứ mà ta cung cấp hơn. Đây là thứ mà client mong muốn:

String productId = productIdGenerator.execute();

Rất đơn giản, họ chỉ cần gọi ProductIdGenerator.execute() là có đươc ProductID mong muốn mà không cần biết cách sinh ra giá trị này thế nào, cấu trúc giá trị đó ra sao. Những thứ này là nghiệp vụ, cần thuộc về Domain Product. Để làm được điều đó, Domain Product cần đóng gói các logic thành phần bên trên vào một class có tên ProductIdGenerator:

public class ProductIdGenerator {
    private final NumberValueGenerator numberValueGenerator
            = new IntegerRandomGenerator();

    String execute() {
        return "PRD-" + StringFormatter.padNumber(
                this.numberValueGenerator.execute(0, 9999999),
                7, '0');
    }
}

Có một số điểm chú ý ở code trên:

  • NumberValueGenerator được chỉ định luôn cách thức sinh số cụ thể là Integer và hơn nữa, còn quy định trong khoảng 0 – 9999999 thay vì đi theo nguyên tắc DIP. Điều này mang lại một số ưu điểm:
    • Giảm đi một lần cấu hình Dependency Injection cho client. Họ sẽ dễ dùng class ProductIdGenerator hơn.
    • Nghiệp vụ quy định ProductID cần có cấu trúc như vậy, vậy thì Domain cung cấp sát yêu cầu đó luôn thay vì để client thực hiện.
    • Client vẫn có thể sử dụng NumberValueGenerator với DIP ở những nơi khác.
    • Điều này biến NumberValueGenerator và các class cài đặt trở thành tổng quát hơn Domain Product này.
  • Phương thức StringFormatter.padNumber thực hiện chèn thêm các ký tự ‘0’ nếu giá trị truyền vào dưới 7 số.

Nếu lõi Domain cần linh hoạt hơn, ta nên áp dụng DIP cho ProductIdGenerator:

Hình 4: Thực hiện DIP với sinh giá trị số

Hình trên cho thấy cơ chế DIP thực hiện đảo ngược phụ thuộc: Thay vì ProductIdGenerator phụ thuộc vào IntegerRandomGenerator như trước thì giờ cả hai cùng phụ thuộc vào NumberValueGenerator – một thành phần cấp cao hơn. ApplicationInitializer thực hiện việc lắp ghép các phụ thuộc để khởi tạo ProductIdGenerator, thông thường, ta sẽ thực hiện ở class với Spring @Configuration.

Để đơn giản cho demo này, ta sử dụng cách thiết kế này với cơ chế sinh dữ liệu là IntegerRandomGenerator.

Nơi sinh id

Như đã thảo luận trong bài viết về Identity, ta có các vị trí sau có thể đặt lời gọi tới ProductIdGenerator, mỗi cách mang ý nghĩa khác nhau tùy theo mục đích mà Domain mong muốn:

  • Sinh bên ngoài Domain. Client được toàn quyền sinh ra giá trị id cho Product.
  • Sinh bên trong Domain. Client không cần quan tâm tới cách sinh id, họ chỉ cần tạo Product.

Bài viết này sẽ tập trung vào cách thứ nhất. Cách thứ hai sẽ bàn tới sau này.

Sinh ID bên ngoài Domain

Nếu Domain mong muốn client tự sinh ProductId để cho họ có thể linh hoạt trong lựa chọn phương án (sinh ngẫu nhiên, lấy từ database, fixed), constructor sẽ có dạng:

public Product(String id) {
    this.id = id;
}

Client sẽ hoạt động như sau:

ProductIdGenerator productIdGenerator = new ProductIdGenerator(
        new IntegerRandomGenerator()
);
String id = productIdGenerator.execute();
Product product = new Product(id);

Code trên cho thấy một client lựa chọn phương án sinh ngẫu nhiên Integer từ bộ thư viện cung cấp. ProductID sinh ra xong mới được đưa vào tạo mới Product. Vấn đề nhỏ cần xem xét: Validate ProductID ở đâu trong đoạn code trên?

Nếu bạn cho rằng ProductIdGenerator cần có trách nhiệm validate cấu trúc id có vẻ phù hợp, nghĩa là id = productIdGenerator.execute() đã thỏa mãn và đưa vào tạo Product ổn thỏa, thì vẫn có một khe hở: Làm thế nào để Product yên tâm rằng id truyền vào là đúng? Không có cách nào ngoài việc nó tự phải re-validate một lần nữa:

public Product(String id) {
    validateId(id);
    this.id = id;
}

Để tránh vi phạm DRY, ta gom hành động validate id trong ProductIdGenerator và trong Product’s constructor lại vào một đối tượng duy nhất.

Hình 5: Hai đối tượng Product và ProductIdGenerator cùng sử dụng ProductIdValidator để validate ProductId

Code thể hiện sơ đồ phụ thuộc trên như sau:

public class ProductIdValidator {
    public void execute(String productId) {
        String expectedFormatRegex = "^PRD-\\d$";
        if (!productId.matches(expectedFormatRegex)) {
            throw new RuntimeException("ProductId is not valid");
        }
    }
}

public class ProductIdGenerator {
    ...
    public String execute() {
        String id = "PRD-" + StringFormatter.padNumber(
                this.numberValueGenerator.execute(0, 9999999),
                7, '0');

        ProductIdValidator productIdValidator = new ProductIdValidator();
        productIdValidator.execute(id);

        return id;
    }
}
public class Product {
    ...
    public Product(String id) {
        ProductIdValidator idValidator = new ProductIdValidator();
        idValidator.execute(id);

        this.id = id;
    }
    ...
}

Có một sự “rườm rà” trong code này: ProductIdGenerator là nơi sinh ra ProductId thì nó cần chứa luôn logic validate sự chính xác của giá trị này thay vì ủy quyền cho ProductIdValidator, tức là trách nhiệm của class này bên cạnh việc sinh ra ProductId, còn có thể validate các giá trị ProductId bên ngoài gửi tới nữa. Theo tôi, đây là một nhận xét xác đáng. Việc đưa sang đối tượng Validator riêng biệt làm module nghiệp vụ bị băm nhỏ hơn. Nếu cứ làm như vậy, ta sẽ có xu hướng tạo ra nhiều class hơn mức cần thiết, kết quả là ta tạo ra những module “nông”. Một quy tắc quan trọng trong thiết kế module là tạo ra những module “sâu”. Ta sẽ gom logic validate vào trong ProductIdGenerator vì tính cohesion cao và đổi tên class này để phù hợp với trách nhiệm mới của nó: ProductIdManager.

Hình 6: ProductIdManager được sử dụng bởi Product

Lúc này, code như sau:

public class ProductIdManager {
    private final NumberValueGenerator numberValueGenerator;
    private static final String expectedFormatRegex = "^PRD-\\d$";

    public ProductIdManager(NumberValueGenerator numberValueGenerator) {
        this.numberValueGenerator = numberValueGenerator;
    }
    
    public String generate() {
        String id = "PRD-" + StringFormatter.padNumber(
                this.numberValueGenerator.execute(0, 9999999),
                7, '0');
        validate(id);
        return id;
    }

    public static void validate(String id) {
        if (!id.matches(expectedFormatRegex)) {
            throw new RuntimeException("ProductId is not valid");
        }
    }
}
public class Product {
    ...
    public Product(String id) {
        ProductIdManager.validate(id);
        this.id = id;
    }
    ...
}

Sự phụ thuộc tồn tại từ Product tới ProductIdManager có thể chấp nhận được vì tính cohesion của chúng.

Cải tiến

Tới đây, ta thấy rằng chỉ vì sử dụng ProductID là String dẫn tới việc ta luôn phải kiểm tra tính đúng đắn của id này tại constructor bằng cách gọi ProductIdManager.validate(id). Mặc dù điều này không gây trở ngại gì trong việc tạo mới Product nhưng có một vài tính huống gây bất tiện:

  • Bất cứ khi nào muốn validate xem một giá trị String có đúng cấu trúc id không, ta đều phải thực hiện lời gọi đó.
  • Bất cứ class nào tiếp nhận tham số String productId đều phải thực hiện validate xem nó có đúng cấu trúc hay không. Đây là phương pháp lập trình phòng thủ (Defensive Programming).
  • Ngoài ra còn một số bất tiện nhỏ không đáng kể.

Những thứ này dẫn ta đến việc tạo ra một Value Object đóng gói logic kiểm tra validation và linh hoạt hơn trong ứng dụng: Bất cứ khi nào một ai có trong tay đối tượng này, họ cứ tự tin sử dụng vì nó đã đúng cấu trúc. Đó chính là class ProductId chuyên trách.

ProductId là một Value Object, bên trong chứa một giá trị String duy nhất là value. Vì nó là Value Object nên hai ProductId được coi là bằng nhau khi value của chúng bằng nhau.

public class ProductId {
    private final String value;

    public ProductId(String value) {
        validate(value);
        this.value = value;
    }
    
    @Override
    public boolean equals(Object o) {
        …
    }

    @Override
    public int hashCode() {
        …
    }

    private static final String expectedFormatRegex = "^PRD-\\d$";
    private void validate(String id) {
        if (!id.matches(expectedFormatRegex)) {
            throw new RuntimeException("ProductId is not valid");
        }
    }
    ...
}

Ta thấy rằng, trong constructor, thực hiện validate cấu trúc value như trước. Vậy, logic validate đã được chuyển từ ProductIdManager sang ProductId. Bất kỳ giá trị value nào không thỏa mãn cũng sẽ có exception ném ra và việc tạo ProductId thất bại.

Hãy xem client sử dụng đối tượng này, trong đó có Product:

public class Product {
    private ProductId id;

    public Product(ProductId id) {
        assert id != null;
        this.id = id;
    }
    ...
}

Code đã tốt hơn khá nhiều: Không cần gọi validate id đầu vào nữa vì một khi nó đã khác null, nó luôn đúng. Phần assert là một thực thi lập trình phòng thủ - là thứ bạn luôn cần làm nếu phương thức tiếp nhận dữ liệu có thể null. Những client khác cũng sẽ tương tự như vậy. Ta thấy rằng, thay đổi này khiến ứng dụng được tăng thêm phần tin cậy.

Với việc đưa validate id vào ProductId, ta sẽ cần đưa ProductIdManager về ProductIdGenerator như ban đầu nhưng tiến hóa hơn để phù hợp hơn một chút:

public class ProductIdGenerator {
    private final NumberValueGenerator numberValueGenerator;

    public ProductIdGenerator(NumberValueGenerator numberValueGenerator) {
        this.numberValueGenerator = numberValueGenerator;
    }

    public ProductId generate() {
        return new ProductId("PRD-" + StringFormatter.padNumber(
                this.numberValueGenerator.execute(0, 9999999),
                7, '0'));
    }
}

Thay vì generate một String, ProductIdGenerator trả về một ProductId. Logic kiểm tra cấu trúc ProductId được đóng gói trong ProductId. Client giờ muốn tạo mới Product, vẫn gần giống như trước:

ProductIdGenerator productIdGenerator = new ProductIdGenerator(
        new IntegerRandomGenerator()
);
ProductId id = productIdGenerator.generate();
Product product = new Product(id);

Đến đây, việc mô hình hóa ProductId có thể coi là tạm ổn. Có thể cần phải tối ưu thêm khi có yêu cầu mới được xác định. Ta đi tới xác định các thuộc tính quan trọng của Product.

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