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

Tản mạn

"To be or not to be."

Làm thế nào để mỗi cá nhân khẳng định được sự tồn tại trong xã hội này? Có những vị công thần khai quốc vang danh sử sách, có những vị tài năng thiên bẩm trong một hay nhiều lĩnh vực cụ thể, hay ngược lại, có những kẻ tội đồ của non sông, vân vân. Họ đều được chúng ta biết đến dù là thế này hay thế khác. Ngày nay, chúng ta đang sống trong một xã hội biến động với tốc độ nhanh chưa từng có, dân số đông chưa từng có và còn tiếp tục tăng cao nữa. Những người bình thường như tôi hay phần lớn các bạn đều không nổi tiếng, có "name" để mọi người biết đến. Nhưng không vì vậy mà chúng ta không được xác định, định vị trong dòng chảy này.

Ta vào làm tại một công ty, ta có mã nhân viên. Chỉ đơn giản vậy thôi mà ta không thể bị nhầm lẫn với người khác trong công ty được, dù có thể ta có người anh em song sinh làm cùng tổ chức đó. Nghĩ lại thì dù ở đâu, tham gia vào bất cứ lĩnh vực, tổ chức nào, chúng ta đều có những dấu hiệu riêng để phân biệt với người khác. Là công dân Việt Nam, ta có mã CCCD để phân biệt chúng ta với nhau. Nhờ mã này mà dù ta có già đi, tóc bạc và rụng, đi giày gì, mặc quần áo nào hay thậm chí cơ thể có không còn nguyên vẹn như ban đầu, TA VẪN LÀ TA NHỜ VÀO SỐ CCCD ĐÓ.

Một dạng số CCCD tự nhiên tôi có thể hình dung là dấu vân tay mà thời phong kiến thường dùng để điểm chỉ. Nếu ta có ứng dụng chuyển từ dấu vân tay trên ảnh đó thành số thì nó hoàn toàn được sử dụng trong hệ thống CNTT (Công nghệ thông tin) để phân biệt nhau.

Một chiếc xe máy bị đánh cắp, làm thế nào cảnh sát có thể biết được dù chiếc xe đó đã bị thay đổi ngoại hình? Dựa vào số khung, số máy. Đó là dấu hiệu xác định tính duy nhất cho chiếc xe. Giờ thì ta đã thấy, xung quanh chúng ta, cái gì cũng có thông tin duy nhất để ĐỊNH DANH. Sản phẩm có mã sản phẩm, ngôi nhà có giấy tờ, địa chỉ,...

Vậy, chúng ta không cần, các thứ khác cũng không cần phải làm gì để khẳng định sự tồn tại cả bởi vì chúng ta và những thứ đó đều nằm trong một hệ thống quản lý dù ở các quy mô khác nhau. Những hệ thống đó gán cho chúng ta những mã định danh để phân biệt ta với người khác. Nhờ vào định danh này, dù ta có thay đổi thế nào thì ta vẫn được xác định là ta. Và, đó chính là ENTITIES.

Giải phẫu một Entity

Cấu trúc Entity

Ở một số bài viết như Anemic Model hay Transaction Script, bạn đã biết về những "thực thể" với dữ liệu thuần túy, cấu trúc của nó rất đơn giản:
 

Hình 1: Anemic Model - một dạng data class thuần túy

Hình 1 cho thấy một Anemic Model với những trang bị trông khá giống một Entity. Nó có ID để định danh, giúp phân biệt nó với bản ghi khác; nó có các thuộc tính với giới hạn truy cập "private" nhưng nó lại chìa ra các getters/setters thuần túy dù bằng code thủ công hay Lombok. Rất hiếm khi nó có những trang bị khác như một "behavior" mà chúng ta vẫn học: Một hành vi nghiệp vụ. Hãy cùng xem và so sánh hai đoạn code sau:

Đoạn 1:

       double fee = calculatedDistance * COST_PER_KM;
    shipment.setFee(fee);
    shipment.setDriverId(driverId);
    shipment.setStatus(ASSIGNED);

Đoạn 2:

    shipment.assignDriver(foundDriver, feeCalculator);

Cách code "đoạn 1" mang tính thủ tục và Shipment chỉ là data container đơn giản. Nhìn vào đoạn code đó, chúng ta (những developer) phải phiên dịch sang ngôn ngữ nghiệp vụ và giải thích với người khác: "Đây nhé, chỗ này là tính phí đơn hàng dựa vào khoảng cách từ vị trí đơn tới tài xế tìm được. Sau đó phí sẽ được set vào đơn. Đơn cũng cần được cập nhật thông tin tài xế phụ trách và chuyển trạng thái sang ASSIGNED". Điều này lặp đi lặp lại, xuất hiện nhiều nơi trong hệ thống khiến "tải nhận thức" của lập trình viên tăng cao làm cho họ gặp nhiều khó khăn trong công việc.

Hãy nhìn vào "đoạn 2". Shipment không còn là data container nữa, nó là một "thực thể" vì ngoài chứa dữ liệu nghiệp vụ, nó còn có cả hành vi nghiệp vụ. Đơn hàng trong domain này cần phải có tài xế phụ trách, do đó, "assignDriver" là hành vi nghiệp vụ lõi của đơn hàng. Thông qua hành vi này, đơn hàng sẽ thay đổi trạng thái nội bộ của nó: Thiết lập phí giao hàng, cập nhật tài xế phụ trách, và cuối cùng là chuyển trạng thái. Nhiệm vụ tìm tài xế phù hợp không phải là trách nhiệm của đơn hàng, nếu làm vậy, nó vi phạm SRP. Giả sử rằng, tìm tài xế là một phần quan trọng của domain hiện tại và việc này có thể thực thi bởi nhiều thuật toán khác nhau (xem hình này), ta nên đưa nghiệp vụ đó vào một đối tượng domain phù hợp. Tại sao feeCalculator lại được đưa vào tham số của hàm? Đó là quan điểm thiết kế. Ở đây, ta đề cao sự thay đổi thuật toán tính phí hơn, điều này giúp shipment đảm bảo được OCP.

Qua ví dụ trên, bạn có thể hình dung được một Entity đúng nghĩa cần có những gì rồi phải không? Có thể lâu nay chúng ta vẫn đang làm được một phần, chỉ là thiếu hành vi nghiệp vụ mà thôi. Nhưng thật ra để đảm bảo một Entity tốt trong DDD, ta cần có nhiều nguyên tắc hơn thế.

Hình 2: Cấu trúc điển hình của một Entity trong DDD

Hình 2 cho thấy một sự cập nhật mới. Ngoài "behaviors" được thêm vào còn có những "Value Objects". Với những ai làm về Java, học thi chứng chỉ Java Core, điều này không lạ lẫm. Value Object là những đối tượng chứa Giá trị (Value) thuần túy. Thế nào là những đối tượng Value thuần túy? Chúng cần thỏa mãn một số tính chất cơ bản sau:

  • Chứa giá trị, tất nhiên rồi. Một Color nên chứa mã màu RGB hay Hex, một Weight nên chứa trọng lượng cùng một đơn vị, một Velocity nên chứa độ lớn cùng đơn vị tương ứng.
  • Bất biến (immutable). Một Color nếu đã tạo ra với mã màu RED thì không thể chỉnh nó sang BLUE. Nếu cần một mã màu BLUE, ta phải tạo ra một Color khác tương ứng.
  • Không có định danh (ID). Do chỉ dựa vào VALUE, các đối tượng này giống nhau hoàn toàn dựa trên VALUE đó. Hai Color cùng chứa mã màu RED cần phải tương đương nhau. Sự tương đương này được thể hiện ở chỗ ta có thể thay thế chúng cho nhau ở nơi sử dụng mà không làm sai lệch chương trình. Ngược lại, nếu sử dụng định danh, hai Color có thể có VALUE khác nhau nhưng vẫn có thể bằng nhau, đây là điều cần tránh khi thiết kế Value Object.
  • Có hành vi nghiệp vụ. Đây chính là một trong những giá trị lớn nhất mà Value Object mang tới trong thiết kế DDD. Ta có thể pha hai Color với nhau để tạo ra một Color mới: Color newColor = red.mix(blue).

Nghe có vẻ mới lạ, thậm chí hơi thái quá. Bạn và tôi đã dùng Value Object rất nhiều rồi. String, Integer, Boolean, ... tất cả đều là Value Object. Chúng ta sử dụng chúng để "trang điểm" cho những Entities. Tuy nhiên, chúng ta sử dụng quá cơ bản và do chúng quá cơ bản nên chúng không thể hiện được giá trị trong bài toán của chúng ta.

Thử ví dụ thiết kế một nghiệp vụ nhỏ sau: Hộp giao hàng trong logistics.

Ví dụ

Nghiệp vụ được mô tả như sau:

  • Mỗi shipper chở một số hộp (Box) khi giao hàng.
  • Mỗi hộp có thể có vật liệu, màu sắc, khối lượng rỗng, và khả năng chứa tối đa (về khối lượng) nhất định.
  • Mỗi loại vật liệu phù hợp với giá trị đơn hàng nhất định.
  • Mỗi hộp có thể chứa nhiều đơn.
  • Mỗi đơn hàng có giá trị (về tiền) và trọng lượng nhất định.
  • Mỗi hộp cần được tracking vị trí.

Cách 1: Sử dụng các Value Object cơ bản.

Hộp được thể hiện bởi class "Box" như dưới đây:

Hình 3: Thiết kế class Box với giải pháp thường gặp

Với thiết kế này, các Value Object cơ bản mà Java cung cấp được thể hiện rõ ràng. Nếu bạn đang làm như vậy, và phần lớn chúng ta có lẽ cũng làm như vậy, thì bạn cần tĩnh tâm xem xét thật kỹ xem có vấn đề gì không? Hãy dựa vào các khung đỏ mà tôi khoanh trên hình để đánh giá. Những thuộc tính trong cùng một khung có mối quan hệ mật thiết với nhau và không thể tách rời. Tôi sẽ không quá sa đà vào thiết kế Value Object trong phạm vi bài này mà để một bài dành riêng cho nó sau.

Vấn đề thứ hai, các thuộc tính mô tả trọng lượng sử dụng đơn vị gì? g, kg, hay gì khác?

Vấn đề nhỏ thứ ba gặp phải, chúng ta không biết được ý nghĩa của thuộc tính trừ khi ta phải nhìn vào tên thuộc tính. Nhìn rõ hơn qua hình sau:

Hình 4: Giấu tên thuộc tính

Hình 4 cho thấy vấn đề nhỏ nhưng sẽ lớn nếu class có bộ thuộc tính phong phú: Kiểu dữ liệu không mô tả được ý nghĩa của nó trong domain.

Giờ cùng đi vào một hành vi nghiệp vụ: Bổ sung đơn vào hộp. Khi đơn được thêm vào hộp, hai điều kiện sau (được gọi là invariants) cần phải đảm bảo:

  1. Hộp phù hợp để chứa đơn hàng có giá trị đó dựa trên vật liệu.
  2. Hộp không vượt quá khối lượng tối đa cho phép.
Hình 5: Hành vi bổ sung đơn hàng vào hộp

Hành vi "addOrder" đã khá tốt: Invariants được cài đặt và đóng gói trọn vẹn. Một số điểm cần lưu ý:

  • Ở phạm vi nhỏ này, code vẫn mang tính kỹ thuật, cần phải "dịch" sang nghiệp vụ.
  • Code ngầm thừa nhận các thuộc tính weight của Box và Order là cùng đơn vị.

Nhận xét chung: Cách 1 đã triển khai một Entity đạt yêu cầu, dù còn một số điểm cần cải thiện nhưng những điều cơ bản nhất của Entity đã được thể hiện. Giờ ta cùng lướt qua cách 2 để thấy sự cải thiện, thể hiện đúng tinh thần DDD: mọi thứ cần thể hiện nghiệp vụ.

Cách 2: Thiết kế Value Object cho domain.

Sự cải thiện lớn nhất ở đây là ta sẽ tạo ra những đối tượng nghiệp vụ có nghĩa trong domain, gán cho chúng các thuộc tính và hành vi phù hợp. Những thuộc tính mà ở hình 3 được gom nhóm với nhau là một gợi ý rất hợp lý. Hãy nhìn vào kết quả:

 

 

Hình 6:

BoxSpec, Weight, Money, Location là những đối tượng mới được tạo ra. Chúng không đơn thuần chỉ là những data container, chúng thực sự có vị trí trong domain của ta.

  1. Weight: bao gồm hai thông tin độ lớn (value) và đơn vị (unit). Nếu bạn định tách rời hai thông tin này ra, mỗi nửa không còn ý nghĩa cho Weight.
  2. Location: cũng tương tự như Weight, một vị trí trên bản đồ không thể chỉ có kinh độ hoặc vĩ độ. Hai thông tin này phải đi cùng nhau mới tạo nên một Location.
  3. Money: cũng tương tự như Weight, cần bao gồm độ lớn và đơn vị. (trên hình không thể hiện đối tượng này).
  4. BoxSpec: đây mới là đối tượng domain mang ý nghĩa lớn của ta. Trong domain của ta, các thuộc tính như vật liệu (material), màu sắc (color), trọng lượng khi không tải (emptyWeight), trọng lượng tải tối đa (maximumWeight) đều là những thuộc tính mô tả cho Box, chúng không thể bị tách rời vì nếu vậy, thông tin sẽ bị phân tán, thiếu trọn vẹn. Bạn có thấy hai hành vi nghiệp vụ được cài đặt cho BoxSpec không? Chúng chính là các Invariants mà domain của chúng ta phải đảm bảo. Giờ thì, thay vì các Invariants được cài đặt trực tiếp tại Box, chúng được chuyển cho BoxSpec. Điều này tạo ra sự tiến bộ vượt bậc trong mô hình hóa đối tượng: Quy định nghiệp vụ về khả năng vận chuyển đơn hàng của Box không phải do Box quy định mà về bản chất phải do thuộc tính của Box quy định. Nếu có thể, sau này trong domain này của ta còn có những đối tượng cũng có khả năng chứa hàng giống Box như Container, Package, Bag, ... thì chúng đều có thể sử dụng thuộc tính mô tả là BoxSpec. Hành vi và dữ liệu đã được đóng gói và định hình tại BoxSpec.
  5. Những Value Object này đã thể hiện khái niệm Conceptual Whole trong thiết kế.
Hình 7: Hành vi addOrder() được thực thi với việc triển khai hai invariants tại BoxSpec

Nhìn vào code ở Hình 7, thoạt nhìn trông vẫn như Hình 5 nhưng sự thay đổi là rất đáng kể:

  1. Hai invariant nghiệp vụ được triển khai tại BoxSpec, được sử dụng qua thuộc tính spec trong Box.
  2. Khối lượng được cộng dồn qua hành vi Weight.add() để đảm bảo dù đơn vị có khác nhau thì kết quả vẫn chính xác.

Câu hỏi nhỏ: Tại sao lại triển khai hai invariants riêng biệt tại spec, isSuitableForOrderValue canHoldWeight, mà không gộp lại thành isBoxSuiltableForOrder(order)?

Tổng kết

Đến lúc này, có lẽ bạn đã hình dung ra được Entity là gì, nên có cấu trúc và mang trách nhiệm gì trong domain. Entity là thành phần lõi quan trọng nhất của domain, không có nó sẽ không có Value Object, Repository, Factory, Domain Event, ...

Vòng đời của Entity và các vai trò của các thành phần khác trong vòng đời này:

Hình 8: Vòng đời của Entity và vai trò các thành phần khác trong vòng đời này

Trong hình 8, vòng đời của Entities trải qua như sau:

  • Sinh ra. Nhờ các cơ chế sinh: Constructors, Factories.
  • Hoạt động. Nhờ các hành vi nghiệp vụ.
  • Lưu trạng thái để hoạt động lâu dài. Nhờ Repositories.
  • Kết thúc. Thông qua Delete hoặc cơ chế Archiving.
Bình luận
Gửi bình luận
Bình luận