logoMột hệ thống khó hiểu thì cũng khó thay đổi
DDD - Part 3: Factory

GoF - nhóm bốn tác giả đã viết ra cuốn sách kinh điển về software design patterns. Trong đó đã đề cập tới 23 patterns phân thành ba nhóm: Creational, Structural, và Behavioral patterns. Là lập trình viên mới ra trường, có ai mà không mong muốn master được chúng? Sau nhiều năm "công tác", cá nhân tôi nhận thấy những mong muốn như vậy thường bị cuốn vào công việc hàng ngày - những thứ yêu cầu rõ ràng một kết quả đầu ra: What chứ không phải How. Do đó, có thể trong một khoảng thời gian "ôn luyện" design patterns, mình cảm thấy đã thành thạo nhưng sau một khoảng thời gian lại cảm thấy quên và khá khó áp dụng. Như chính Martin Fowler có nói về cách áp dụng patterns, đại khái là nhớ được có pattern như vậy và quan trọng nhất là nhận biết được tình huống mà những pattern có thể áp dụng. Còn khi cài đặt, ta sẽ cần tài liệu tham chiếu. Đó là phương pháp có lẽ ông đã đúc kết từ thực tế vì có lẽ rất ít người có thể nhớ được chi tiết từng sơ đồ của từng pattern. Sau này, khi kinh nghiệm được đúc kết, chúng ta sẽ không chỉ làm việc với những pattern mà GoF đã tạo ra mà còn rất nhiều pattern khác thuộc nhiều lĩnh vực khác nhau:

  • Enterprise Application Patterns: EEA
  • Enterprise Integration Patterns: EIP
  • Các pattern về thiết kế domain models.
  • Các pattern về thiết kế database models.
  • Các pattern trong một lĩnh vực nghiệp vụ cụ thể.
  • ...

Nhưng có lẽ bạn và tôi đều nhận thấy có những thứ rất khó quên, tất nhiên trong phạm vi phần mềm, như Singleton, Facade, Observer, Builder,... Có lẽ bởi tính dễ hiểu và áp dụng rộng rãi của chúng. Và, Factory cũng không phải là ngoại lệ. GoF Factory là một pattern đủ tốt để áp dụng tại nhiều nơi trong code khi ta cần tạo mới đối tượng. Với DDD, nó còn mang tính trực diện hơn: Tạo ra các đối tượng domain. Trong phạm vi bài viết này, chúng ta chỉ đề cập tới những khía cạnh mà Factory phù hợp với DDD.

Động lực

Như thường lệ, code là ngôn ngữ chung của chúng ta, những developer. Cùng xem lại ví dụ ở bài viết trước, về Box class.

Hình 1: Box Entity trong ví dụ trước

Class Box hiện tại chỉ bao gồm hai hành vi nghiệp vụ đơn giản là addOrder() và updateLocation(). Điều này chưa nêu bật được vai trò của Factory và nó cũng không phản ánh được thực tế. Giả sử rằng, bounded context này có những nhóm nghiệp vụ sau:

  • Đóng gói và tháo dỡ đơn hàng: Thêm/bớt đơn ra khỏi Box.
  • Theo dõi trạng thái vận chuyển.
  • Tính toán chi phí dựa trên đặc điểm của Box và đơn hàng.
  • Kiểm tra tính hợp lệ của việc đóng gói.

Với những nghiệp vụ trên, giả sử Box giờ đây có những hành vi sau:

  • addOrder: Thêm đơn.
  • removeOrder: Bỏ đơn.
  • sealBox: Đóng hộp.
  • unsealBox: Mở hộp.
  • updateLocation: Cập nhật vị trí di chuyển.
  • startTransport: Chuyển trạng thái hộp sang vận chuyển.
  • markAsDelivered: Chuyển trạng thái hộp sang hoàn hành giao.
  • calculateTransportCost: Tính phí vận chuyển, giả sử dựa trên trọng lượng và vật liệu.
  • canAcceptOrder: Kiểm tra khả năng chứa.
  • ...

Một đối tượng mang nhiều nghiệp vụ cốt lõi trong hệ thống.

Hình 2: Các thuộc tính của Box

Đây là một đối tượng quan trọng trong domain vì nó mang trong mình dữ liệu (Critical Business Data) và cả hành vi nghiệp vụ (Critical Business Rules) - là những thứ tạo ra Invariants. Những đối tượng như Box, BoxSpec, ... trong domain vận hành, phối hợp với nhau mang lại giá trị cho doanh nghiệp. Chúng cũng giống như cỗ máy trong chiếc đồng hồ cơ của bạn vậy: Ổ cót chậm rãi nới từng nấc cót, thông qua một cơ chế bánh răng truyền động, năng lượng mà cót tạo ra làm dịch chuyển cả một cỗ máy, và kết quả là kim đồng hồ dịch chuyển. Giá trị mang lại của nó là giúp chúng ta ghi nhận thời gian, chỉ đơn giản vậy mà cả một cỗ máy bao gồm hàng trăm bộ phận cần phối hợp nhịp nhàng với nhau.

Nhưng chiếc đồng hồ tới tay chúng ta chỉ là sản phẩm mang lại giá trị kinh doanh (ở đây là chỉ giờ), chúng cần phải được lắp ráp. Ai có thể lắp ráp được chúng?

  1. Chúng ta, những kỹ sư phần mềm? Khả năng cao là không, trừ khi bạn đã từng trải nghiệm lĩnh vực này. Chúng ta chỉ là người dùng, user, thuần túy, ta không thể biết được bên trong cỗ máy có cấu trúc thế nào, và tất nhiên, tường là ta không cần biết! Giả sử ta biết thì việc lắp ráp cũng vô cùng khó khăn. Hồi còn học tiểu học, tôi đã phá tầm hơn 10 chiếc đồng hồ cơ mà bố mẹ tôi mang từ Liên Xô về. Tôi tháo chúng ra rồi không biết ráp lại thế nào.
  2. Bản thân chiếc đồng hồ? Tất nhiên là nó không phải là Đồng Hồ Robot - nó chỉ là cỗ máy cơ học thuần túy.
  3. Nghệ nhân đồng hồ. Tôi bỏ qua máy ráp đồng hồ vì tôi đang nghĩ tới những chiếc Rolex xa xỉ, được lắp ráp bởi những nghệ nhân bậc nhất thế giới.

Qua ba ý trên, ta thấy rằng, ý (1) không phù hợp nếu như cỗ máy phức tạp được giao cho người dùng tự tạo ra. Khi đó, không chỉ ta đã chuyển trách nhiệm sang cho họ (đối tượng thuộc domain lại giao cho bên ngoài lắp ráp) mà ta còn khiến cho đối tượng đó bị mất tính đóng gói. Ý (2) thì tất nhiên không thể làm được, nhưng ta thấy rằng một cỗ máy phức tạp trong hoạt động thì đừng giao cho nó thêm nhiệm vụ lắp ráp chính nó (điều này khả thi trong phần mềm mà ta vẫn thường làm).

Vậy, ta nên chuyển trách nhiệm này cho một đối tượng riêng trong domain: các Factory.

Factory không phải đơn thuần là một class mà ta vẫn làm như áp dụng GoF mà giờ đây, nó là một thành phần trong domain của ta: Đối tượng domain đi đâu, Factory của nó đi theo tới đó. Nếu một đối tượng đã có Factory thì đó nên là điểm truy cập duy nhất trong domain để tạo ra đối tượng đó.

Giờ ta cùng xem các cách triển khai một Factory trong DDD.

Domain Object Creation

Qua phần trên bạn đã thấy rằng quyết định lựa chọn triển khai một Factory cho đối tượng domain không nhất thiết phải dập khuôn bằng cách đối tượng nào cũng cần phải có một Factory mà quyết định đó nên dựa trên độ phức tạp của đối tượng (yếu tố chính) và tính đóng gói (yếu tố phụ). Vậy, cùng xem lại các cách tạo ra một đối tượng domain và đánh giá khi nào nên dùng chúng.

Constructor

Sử dụng Constructor là cách thường gặp nhất. Vấn đề với Constructor là code của client phụ thuộc trực tiếp vào chính class được tạo, và do đó, nếu code này cần mức uyển chuyển hơn khi class được tạo có tính đa hình, ta cần sử dụng Abstract Factory. Nhưng hãy tạm bỏ qua điều này bằng ví dụ sau. Ở kiến trúc Hexagonal, client của Domain chính là tầng Application chứa các Use Case. 

Use case: Tạo mới Box cho một chuyến giao hàng.

Hình 3: Use case tạo mới Box cho một chuyến hàng

Hình 3 cho thấy client của Domain, Use Case, gọi trực tiếp Box constructor qua lời gọi: "Box box = new Box(...);". Client cảm thấy khá happy vì cách sử dụng tương đối dễ dàng, chỉ cần truyền các tham số cần thiết là có đối tượng để dùng. Dù danh sách tham số là nhiều, có phần gây khó khăn cho việc sử dụng nhưng ta tạm thời bỏ qua điều này. Bên trong constructor:

Hình 4: Constructor của Box

Ái chà! Khá nhiều thứ đã thực hiện trong này: một danh sách dài các validation, chuẩn hóa dữ liệu, và cuối cùng là khởi tạo trạng thái của Box. Dù đây chỉ là minh họa cho tình huống constructor cần làm nhiều việc nhưng trong thực tế, có khá nhiều class tại các dự án làm điều này. 

Giá trị quan trọng nhất mà Box mang lại là hoạt động của nó trong vòng đời kinh doanh. Nếu giao thêm cho nó nhiệm vụ lắp ráp chính nó, Box sẽ bị quá tải.

Nói vậy chứ giờ ta xem tình huống tiếp theo:

Hình 5: Use case và constructor đơn giản

Hình 5 với cách code tương tự Hình 3 và 4 nhưng lại rất ổn. Đối tượng User vẫn đóng vai trò quan trọng trong kinh doanh với các dữ liệu và hành vi nghiệp vụ, nó còn đảm nhận cả vai trò lắp ráp chính nó bởi vai trò này đơn giản, dễ thực hiện hơn nhiều so với Box bên trên.

 Vậy bạn có thể lựa chọn tùy theo tình huống thực tế mà đối tượng domain của bạn đang có.

Factory Method

Triển khai Factory Method là dạng rất thường gặp khi cài đặt Factory pattern: ẩn đi constructor, chìa ra một static method đóng vai trò là Factory.

Với Box class, việc sử dụng chỉ thay vì "new Box..." thì ta gọi method này:

Box box = Box.createNewBox(boxId, material, color, emptyWeightKg, maximumWeightKg, initialLocation, warehouseCode);

Trách nhiệm của Box class không thay đổi so với constructor bên trên.

Use Case

Giao cho Box có vẻ không ổn, giờ ta thử đặt vào tay client xem sao.

Khi đặt trách nhiệm khởi tạo Box instance vào tay client, Box constructor không thực hiện các cơ chế validate nữa, giao diện của nó cũng thay đổi để phù hợp, và các thông tin truyền vào được sử dụng ngay.

Hình 6: Box constructor không còn "gánh" nhiều logic khởi tạo nữa

Trách nhiệm khởi tạo được đẩy ra client, tức Use case:

Hình 7: Trách nhiệm tạo Box đặt vào client

Người phụ trách Domain happy vì đẩy được quả bóng sang chân khách hàng của mình, những người sử dụng Box class. Hình 7 cho thấy điều rất tệ:

  • Bao nhiêu logic phức tạp, đáng lẽ thuộc Domain thì lại bị rò rỉ ra bên ngoài.
  • Khách hàng của Domain phải hiểu tường tận cách lắp ghép Box.
  • Nếu có nhiều nơi tạo Box, sẽ nhiều khách hàng bực dọc hơn, dần dần họ sẽ xa lánh, không còn muốn sử dụng sản phẩm của ta nữa.

Đây dường như là cách mà ta luôn nên tránh, nhất là khi đang áp dụng DDD.

Factory Class

Giờ là lúc Factory class đóng vai trò ngôi sao sáng. Cách chuyển đổi rất đơn giản:

  • Tạo mới class mang tên BoxFactory với method: createNewBox() với những tham số cần thiết.
  • Chuyển toàn bộ code khởi tạo Box trong constructor cũ và có thể (tùy chọn) cả cơ chế tạo BoxId vào trong createNewBox().
Hình 8: BoxFactory chứa logic khởi tạo Box

Tạo ra BoxFactory mới chỉ là một nửa câu chuyện. Hiện tại, Domain của ta đang cung cấp hai cách tạo mới Box: qua Box constructor và qua BoxFactory.

  • Constructor chỉ tạo ra đối tượng Box mà không có cơ chế validation cần thiết, do đó, đối tượng có thể không hợp lệ. Nếu để client sử dụng, họ có thể lưu vào database hoặc công bố domain event không hợp lệ.
  • BoxFactory tạo ra đối tượng bao gồm đầy đủ các bước validation hay bất cứ logic nào cần thiết. Nếu vi phạm điều kiện nào đó, Factory sẽ throw exception và đối tượng không được khởi tạo. Còn nếu như client nhận được đối tượng trả về, chắc chắn đối tượng đó là hợp lệ. Từ đó, client có thể làm gì tùy họ vì trạng thái của Domain là hợp lệ.

Vậy ta cần ngăn chặn client sử dụng constructor và chỉ cho phép họ dùng BoxFactory. Điều này được giải quyết bằng cách đóng gói module:

  • Ta đặt tất cả các class của domain này vào cùng một package, trong đó có Box, BoxFactory.
  • Chuyển access modifier của Box constructor:
    • Từ public thành default.
  • Giữ nguyên access modifier của BoxFactory.createNewBox() là public

Nhờ vậy, domain của ta trở thành một module thống nhất mà đối với client, họ chỉ có một điểm truy cập duy nhất để tạo ra một Box instance là BoxFactory.

Hình 9: Module Shipmen chìa ra BoxFactory và các hành vi nghiệp vụ của Box

Hình 9 cho thấy module, mà tôi tạm gọi là Shipment Module, chứa các class mà chúng ta đã bàn tới bên trong (và nhiều class khác nữa). Module chìa ra những thứ sau để client có thể sử dụng:

  • BoxFactory: làm điểm duy nhất giúp client tạo Box.
  • Sau khi có Box instance trong tay, client có thể gọi các hành vi nghiệp vụ của nó.
  • Các class cần public khác.

Factory trở thành một building block của DDD.

Factory Domain Object

Tạo các Factory cho các Domain Objects phức tạp thường là điều tốt. Nhưng có những tình huống bạn cần code tự nhiên hơn nữa.

Để có ví dụ minh họa, giờ ta sẽ cần thêm chút thông tin nghiệp vụ. Giả sử việc tạo Box thường được tiến hành tại các kho Warehouse, sau đó Box mới được sử dụng vào nghiệp vụ.

Nếu sử dụng Factory thuần túy, ta vẫn đạt được mục tiêu như trên với BoxFactory. Nhưng vì Box được tạo tại Warehouse với mã warehouse và location hiện tại của warehouse nên rất tự nhiên nếu ta để Warehouse sinh ra Box:

Hình 10: Warehouse Entity đóng vai trò BoxFactory

Code sử dụng trông sẽ thế này:

Hình 11: Client sử dụng warehouse để tạo Box

Hình 11 cho thấy nghiệp vụ thể hiện việc tạo Box phải gắn kèm với Warehouse. Warehouse trở thành một native Box Factory của Box. Code lúc này trông tự nhiên, dễ hiểu hơn BoxFactory thuần túy. Nếu gặp tình huống tương tự, bạn hãy cố gắng chuyển sang cách viết như vậy để tăng tính mô tả của code lên. Tuy nhiên, hãy cân nhắc nếu như trách nhiệm của đối tượng như Warehouse đang nhiều hay ít nhé!

Trong một số tình huống, ta có thể hi sinh sự phức tạp nội bộ của domain mà ta đang phụ trách để giúp cho client của ta dễ dùng hơn (ở đây là tính tự nhiên của nghiệp vụ thể hiện trong code).

Factory áp dụng cho Entity hay cả Value Object?

 

 

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