Domain Driven Design (Phần 2)

Phần trước mình đã tóm lược về kiến trúc của Domain Driven Design (DDD). Phần này mình sẽ tập trung đi sâu vào các khuôn mẫu (building blocks) được sử dụng trong DDD. Mục đích của những khuôn mẫu này là để trình bày một số yếu tố chính của mô hình hóa hướng đối tượng và thiết kế phần mềm từ quan điểm của DDD. Dưới đây là sơ đồ các khuôn mẫu sẽ được trình bày và các mối quan hệ giữa chúng.

Building blocks.png

Chúng ta có thể thấy ở đây có các pattern như Entity, Value Object, Aggregate... Chúng có thể phân thành 2 nhóm, dựa theo mục đích: một nhóm gồm Entity, Value Object, Service là nhóm pattern dùng để biểu diễn mối quan hệ giữa các đối tượng, quy luật model, kết nối phần phân tích model với mã nguồn cài đặt model. Nhóm còn lại bao gồm Aggregate, Factory và Repository dùng để quản lý vòng đời các đối tượng trong domain.

Entities (Reference Objects)

Trong các đối tượng của một phần mềm, có một nhóm các đối tượng có định danh riêng, những định danh - tên gọi này của chúng được giữ nguyên xuyên suốt trạng thái hoạt động của phần mềm. Đối với những đối tượng này thì các thuộc tính của chúng có giá trị như thế nào không quan trọng bằng việc chúng tồn tại liên tục và xuyên suốt quá trình của hệ thống, thậm chí là sau cả khi đó. Chúng được gọi tên là thực thể - Entity.

Lấy ví dụ, để tạo một lớp Person chứa thông tin về một người chúng ta có thể tạo Person với các trường như: tên, ngày sinh, nơi sinh v.v... Những thuộc tính này có thể coi là định danh của một người không? Tên thì không phải vì có thể có trường hợp trùng tên nhau, ngày sinh cũng không phải là định danh vì trong một ngày có rất nhiều người sinh ra, và nơi sinh cũng vậy. Một đối tượng cần phải được phân biệt với những đối tượng khác cho dù chúng có chung thuộc tính đi chăng nữa. Việc nhầm lẫn về định danh giữa các đối tượng có thể gây lỗi dữ liệu nghiêm trọng.

Do vậy, để có thể viết được một Entity trong phần mềm chúng ta cần phải tạo một định danh. Đối với một người đó có thể là một tổ hợp của các thông tin như: tên, ngày sinh, nơi sinh, tên bố mẹ, địa chỉ hiện tại... Đối với một tài khoản ngân hàng thì số tài khoản là đủ để tạo định danh. Thông thường định danh là một hoặc một tổ hợp các thuộc tính của một đối tượng, chúng có thể được tạo để lưu riêng cho việc định danh, hoặc thậm chí là hành vi. Điểm mấu chốt ở đây là hệ thống có thể phân biệt hai đối tượng với hai định danh khác nhau một cách dễ dàng, hay hai đối tượng chung định danh có thể coi là một. Nếu như điều kiện trên không được thỏa mã, cả hệ thống có thể sẽ gặp lỗi.

Value Objects

Ở phần trên chúng ta đã đề cập đến các thực thể và tầm quan trọng của việc xác định chúng trong giai đoạn đầu của việc thiết kế. Vậy nếu thực thể là những đối tượng cần thiết trong mô hình domain thì liệu chúng ta có nên đặt tất cả các đối tượng đều là các thực thể không? Và mọi đối tượng có cần phải có định danh riêng hay không?

Chúng ta có thể theo thói quen đặt tất cả các đối tượng là các thực thể. Ta có thể truy vết các thực thể. Nhưng việc truy vết và tạo các định danh có giá của nó. Chúng ta phải chắc rằng mỗi thể hiện có một định danh duy nhất, mà việc truy vết định danh thì không phải là đơn giản. Chúng ta cần cẩn thận trong việc xác định định danh, bởi nếu ta quyết định sai thì có thể dẫn tới 2 hay nhiều đối tượng khác nhau mà có chung định danh. Vấn đề khác của hành động đặt tất cả các đối tượng thành thực thể đó là hiệu năng. Vì khi đó phải có từng thể hiện cho mọi đối tượng. Ví dụ nếu Customer là một đối tượng thực thể, thì mỗi thể hiện của đối tượng này, đại diện cho một vị khách cụ thể của ngân hàng, không thể được sử dụng lại cho bất cứ thao tác tài khoản nào của khách hàng khác. Kéo theo đó là ta phải tạo từng thể hiện cho mọi khách hàng trên hệ thống. Việc này sẽ ảnh huởng tới hiệu năng của hệ thống khi ta có tời hàng ngàn thể hiện.

Có những lúc mà ta cần có các thuộc tính của một phần tử trong domain. Khi đó ta không quan tâm đó là đối tượng nào, mà chỉ quan tâm thuộc tính nó có. Một đối tượng mà được dùng để mô tả các khía cạnh cố định của một domain, và không có định danh, được gọi tên là Value Object.

Value Object thể hiện những thành phần hoặc khái niệm của domain mà chỉ được biết bằng đặc điểm của nó. Chúng được dùng như mô tả của thành phần trong model, và không yêu cầu định danh. Value Object không cần định danh bởi vì chúng luôn kết hợp với một đối tượng khác và do đó luôn có thể hiểu được trong ngữ cảnh cụ thể. Chẳng hạn, bạn có một Entity là Order, và dùng Value Object để biểu diễn địa chỉ đặt hàng, item, thông tin chuyển phát... Không có bất kỳ đặc tính nào ở trên cần được định danh bởi vì chúng chỉ có ý nghĩa khi được gắn liền với một order. Một địa chỉ đặt hàng mà không gắn liền với một đơn hàng thì rõ ràng không có ý nghĩa, không ai biết địa chỉ đấy dùng để làm gì. Value Object được so sánh dựa trên các thuộc tính của chúng.

Một điểm quan trọng là Value Object được xem như là bất biến, chúng được tạo bởi các hàm constructor, và không bao giờ được thay đổi trong vòng đời của mình. Ưu điểm của nó là nhờ không có định danh nên có thể tạo và hủy dễ dàng.

Vì những đặc điểm này nên ta cần phân biệt rõ Entity Object và Value Object. Lấy ví dụ một hóa đơn tiền điện chẳng hạn, rõ ràng với vai trò là hộ gia đình, bạn chỉ quan tâm đến số tiền được ghi trên tờ hóa đơn chứ chả cần biết mã số của tờ hóa đơn này là bao nhiêu, lúc này tờ hóa đơn được xem như Value Object. Ngược lại, bên điện lực có thể cần các mã số để lưu trữ những tờ hóa đơn này, lúc này tờ hóa đơn có thể xem như một thực thể.

Service

Khi phân tích domain để xác định những đối tượng chính có trong mô hình chúng ta sẽ gặp những thành phần không dễ để có thể gán chúng cho một đối tượng nhất định nào đó. Một đối tượng thông thường được xem là sẽ có những thuộc tính - những trạng thái nội tại - được quản lý bởi đối tượng đó, ngoài ra đối tượng còn có những hành vi. Tuy nhiên có một số hành vi trong domain lại không thuộc về một đối tượng nhất định nào cả. Chúng thể hiện cho những hành vi quan trọng trong domain nên không thể bỏ qua chúng hoặc gắn vào các Entity hay Value Object. Việc thêm hành vi này vào một đối tượng sẽ làm mất ý nghĩa của đối tượng đó, gán cho chúng những chức năng vốn không thuộc về chúng.

Đối với những hành vi như vậy trong domain, cách tốt nhất là khai báo chúng như là một Service. Một Service không có trạng thái nội tại và nhiệm vụ của nó đơn giản là cung cấp các chức năng cho domain. Service có thể đóng một vai trò quan trọng trong domain, chúng có thể bao gồm các chức năng liên quan đến nhau để hỗ trợ cho các Entity và Value Object. Việc khai báo một Service một cách tường minh là một dấu hiệu tốt của một thiết kế cho domain, vì điều này giúp phân biệt rõ các chức năng trong domain đó, giúp tách biệt rạch ròi khái niệm. Sẽ rất khó hiểu và nhập nhằng nếu gán các chức năng như vậy vào một Entity hoặc một Value Object vì lúc đó chúng ta sẽ không hiểu nhiệm vụ của các đối tượng này là gì nữa.

Service đóng vai trò là một interface cung cấp các hành động. Chúng thường được sử dụng trong các framework lập trình, tuy nhiên chúng cũng có thể xuất hiện trong tầng domain. Khi nói về một Service người ta không quan tâm đến đối tượng thực hiện Service đó, mà quan tâm tới những đối tượng được xử lý bởi Service. Theo cách hiểu này, Service trở thành một điểm nối tiếp giữa nhiều đối tượng khác nhau. Đây chính là một lý do tại sao các Service không nên tích hợp trong các đối tượng của domain, làm như thế sẽ tạo ra các quan hệ giữa các đối tượng mang chức năng và đối tượng được xử lý, dẫn đến ghép nối chặt giữa chúng. Đây là dấu hiệu của một thiết kế không tốt, mã nguồn chương trình sẽ trở nên rất khó đọc và hiểu, và quan trọng hơn là việc sửa đổi hành vi sẽ khó khăn hơn nhiều.

Một Service không nên bao gồm các thao tác vốn thuộc về các đối tượng của domain. Sẽ là không nên cứ có bất kỳ thao tác nào chúng ta cũng tạo một Service cho chúng. Chỉ khi một thao tác đóng một vai trò quan trọng trong domain ta mới cần tạo một Service để thực hiện.

Aggregate

Entity và Value Object hình thành nên những mối quan hệ phức tạp trong domain model. Khi hệ thống lớn và có sự kết nối, tương tác giữa nhiều đối tượng, việc đảm bảo tính tương tranh và nhất quán trở nên khó khăn. Chẳng hạn, chúng ta không muốn chặn một khách hàng cập nhật địa chỉ của anh ta chỉ bởi vì cùng lúc đó một đơn hàng đang được xử lý. Hai điều này rõ ràng chẳng hề liên quan tới nhau, và do đó, không nên cùng chia sẻ một ranh giới (boundary) về tính tương tranh và sự nhất quán.

Do vậy, chúng ta cần sử dụng Aggregate. Một Aggregate là một nhóm các đối tượng, nhóm này có thể được xem như là một đơn vị thống nhất đối với các thay đổi dữ liệu. Một Aggregate được phân tách với phần còn lại của hệ thống, ngăn cách giữa các đối tượng nội tại và các đối tượng ở ngoài. Mỗi Aggregate có một "gốc" (Aggregate root), đó là một Entity và cũng là đối tượng duy nhất có thể truy cập từ phía ngoài của Aggregate. Gốc của Aggregate có thể chứa những tham chiếu đến các đối tượng khác trong Aggregate, và những đối tượng trong này có thể chứa tham chiếu đến nhau, nhưng các đối tượng ở ngoài chỉ có thể tham chiếu đến gốc của Aggreagte. Nếu như trong Aggregate có những Entity khác thì định danh của chúng là nội tại, chỉ mang ý nghĩa trong Aggregate.

aggregate.jpg

Vậy bằng cách nào mà Aggregate có thể đảm bảo được tính toàn vẹn và các ràng buộc của dữ liệu? Vì các đối tượng khác chỉ có thể tham chiếu đến gốc của Aggregate, chúng không thể thay đổi trực tiếp đến các đối tượng nằm bên trong mà chỉ có thể thay đổi thông qua gốc, hoặc là thay đổi gốc Aggregate trực tiếp. Tất cả các thay đổi của Aggregate sẽ thực hiện thông qua gốc của chúng, và chúng ta có thể quản lý được những thay đổi này, so với khi thiết kế cho phép các đối tượng bên ngoài truy cập trực tiếp vào các đối tượng bên trong thì việc đảm bảo các invariant sẽ đơn giản hơn nhiều khi chúng ta phải thêm các logic vào các đối tượng ở ngoài để thực hiện. Nếu như gốc của Aggregate bị xóa và loại bỏ khỏi bộ nhớ thì những đối tượng khác trong Aggregate cũng sẽ bị xóa, vì không còn đối tượng nào chứa tham chiếu đến chúng.

Gộp các Entity và các Value Object thành những Aggregate và tạo các đường biên giữa chúng. Lựa chọn một Entity làm gốc cho một Aggregate và quản lý truy cập tới các đối tượng trong đường biên thông qua gốc. Chỉ cho phép các đối tượng bên ngoài lưu tham chiếu đến gốc. Các tham chiếu tạm thời tới các đối tượng nội bộ có thể được chép ra ngoài để sử dụng cho từng thao tác một. Vì gốc quản lý truy cập nên gốc phải biết đến mọi thay đổi nội tại của Aggregate. Cách thiết kế này giúp đảm bảo các ràng buộc trên các đối tượng của Aggregate cũng như toàn bộ Aggregate.

Untitled-1.jpg

Factory

Như chúng ta biết trong lập trình hướng đối tượng khi khi một client object muốn khởi tạo một object khác trong quá trình xử lý dữ liệu của nó sẽ gọi constructor của object đó và truyền vào đó một vài tham số. Nhưng vì các Entity và Aggregate có xu hướng trở nên phức tạp nên công việc tạo contructor cho một gốc của Aggregate là một công việc quá sức, đồng thời nó cũng đòi hỏi phải hiểu rõ về cấu trúc bên trong của object và các mối quan hệ bên trong object đó cũng như các luật áp dụng cho chúng, việc này dẫn tới hệ quả là nó phá vỡ tính đóng gói của các domain object như Aggregate. Thế nên công việc này sẽ được chuyển giao cho Factory. Đây là cách áp dụng Design pattern “Factory” khá nổi tiếng và phổ biến vào kiến trúc chung của DDD.

Repository

Trong thiết kế hướng lĩnh vực, đối tượng có một vòng đời bắt đầu từ khi khởi tạo và kết thúc khi chúng bị xóa hoặc lưu trữ. Một constructor hoặc một Factory sẽ lo việc khởi tạo đối tượng. Toàn bộ mục đích của việc tạo ra các đối tượng là sử dụng chúng. Trong một ngôn ngữ hướng đối tượng, người ta phải giữ một tham chiếu đến một đối tượng để có thể sử dụng nó. Để có một tham chiếu như vậy, client hoặc là phải tạo ra các đối tượng hoặc có được nó từ nơi khác, bằng cách duyệt qua các liên kết đã có. Ví dụ như, để có được một Value Object của một Aggregate, client cần yêu cầu nó từ gốc của Aggregate. Vấn đề là bây giờ client cần có một tham chiếu tới Aggregate root. Đối với các ứng dụng lớn, điều này trở thành vấn đề vì người ta phải đảm bảo client luôn luôn có một tham chiếu đến đối tượng cần thiết, hoặc tới chỗ nào có tham chiếu tới đối tượng tương ứng. Sử dụng một quy định như vậy trong thiết kế sẽ buộc các đối tượng giữ một loạt các tham chiếu mà có thể nó không cần. Nó tăng các ghép nối, tạo một loạt các liên kết không thực sự cần thiết.

Vì thế, ta sử dụng một Repository, mục đích của nó là để đóng gói tất cả các logic cần thiết để thu được các tham chiếu đối tượng. Các đối tượng domain sẽ không cần phải xử lý với infrastructure để lấy những tham chiếu cần thiết tới các đối tượng khác của domain. Chúng sẽ chỉ lấy nó từ Repository và model lấy lại được sự rõ ràng và tập trung.

Repository có thể lưu trữ các tham chiếu tới một vài đối tượng. Khi một đối tượng được khởi tạo, nó có thể được lưu lại trong Repository, và được lấy ra từ đây để có thể sử dụng sau này. Nếu phía client yêu cầu đối tượng từ Repository, và Repository không chứa chúng, nó có thể sẽ được lấy từ bộ nhớ. Dù bằng cách nào, các Repository hoạt động như một nơi lưu trữ các đối tượng cho việc truy xuất đối tượng toàn cục.

Tổng kết

Trong bài này mình đã cố gắng giới thiệu những vấn đề cơ bản xung quanh các khối ghép để xây dựng nên DDD. Tất nhiên, việc phát triển các ứng dụng với DDD vẫn là một thách thức lớn và cần luyện tập, càng nhiều thì rõ ràng việc thiết kế model của bạn sẽ càng chính xác. Mong rằng qua bài viết thì bạn có thể có được cái nhìn tổng quan về việc triển khai DDD.

Thank you for reading.

Tham khảo: