Kết hợp các mẫu kiến trúc / pattern vào trong một (DDD, Hexagonal, Onion, Clean, CQRS, …)

Trong các bài viết trước, chúng ta đã điểm qua khá nhiều các concept, principle và pattern trong Software Architecture world. Mỗi pattern mang đến cách giải quyết cho những vấn đề khác nhau, vậy câu hỏi đặt ra là liệu chúng ta có thể kết hợp chúng lại để mang đến giải pháp toàn năng hơn có thể giải quyết những bài toán trong thế giới thực? Câu trả lời là có, và trong bài viết này, hãy cùng điểm qua và kết hợp chúng lại, và cùng xem kết quả cuối cùng như thế nào. Đây là một bài viết rất dài, tuy nhiên lại là bài ĐÁNG ĐỌC nhất để cho chúng ta thấy big picture về toàn bộ architecture của system.

Fundamental blocks of the system

Hãy bắt đầu bằng cách nhớ lại mẫu kiến trúc EBIPorts & Adapters (aka Hexagonal). Cả hai đều làm rõ ra những mã nguồn nào thuộc về bên trong (internal) của application, những mã nguồn nào sẽ nằm ngoài (external) của application và những mã nguồn nào dùng để kết nối 2 phần trong và ngoài đó.

Hơn nữa, kiến trúc Ports & Adapters còn chỉ ra 3 loại mã nguồn chính trong system:

  • Mã nguồn thuộc về User Interface
  • Mã nguồn thuộc về Business Logic hoặc Application Core được call bởi User Interface để thực thi các tác vụ
  • Mã nguồn thuộc về Infrastructure dùng để kết nối application với các tools như database, search engine hoặc APIs của bên thứ 3.

1

Application Core là phần chúng ta thực sự phải chú ý tới. Đó chính là trái tim của application, hay nói cách khác, nó chính là application CỦA CHÚNG TA. Nó có thể dùng nhiều thể loại user interface khác nhau như (progressive web app, mobile, CLI, API, …) nhưng bên trong lõi ứng dụng (application core), chúng hoạt động như nhau và không cần quan tâm đến UI nào đang gọi đến.

Bạn có thể tưởng tượng, 1 application flow phổ biến sẽ đi từ UI qua Application Core rồi đến Infrastructure, sau đó quay ngược lại Application Core và reponse lại kết quả cho UI.

2

Tools

Phía ngoài phần Application Core chúng ta có các tools được sử dụng bởi application ví dụ như là database engine, search engine, web server hay CLI Console.

3

Ở đây cần phân biệt rõ về 2 loại tools. Một dạng dùng với mục đích “tell” application to do something như CLI Console, một dạng khác được “told” bởi application để do something như database. Việc phân biệt này rất quan trọng, chúng ta cần phải phân biệt rạch ròi giữa 2 dạng để có thể tạo ra các chiều liên kết, phụ thuộc đúng hướng.

Connecting the tools and delivery mechanisms to the Application Core

Adapters

Code unit dùng để kết nối tool và application core được gọi là adapters (Ports & Adapters Architecture). Apdater sẽ implement interface cho phép application core communicate với tool và ngược lại.

Những Adapters “tell” application to do something được gọi là Primary hoặc Driving Adapters, và ngược lại những Adapters được “told” bởi application được gọi là Secondary hoặc Driven Adapters.

Ports

Adapters không hiển nhiên được ta ra. Chúng được tạo ra để dùng tại những điểm đầu vào (entry point) của Application Core, được gọi là Port. Ports đơn giản là những đặc tả của application cho biết the outside world làm cách nào để tương tác với Application Core. Ví dụ trong Java, Ports có thể là interface và có thể có thêm DTOs (Data Transfer Object).

Một điểm chú ý quan trọng là Ports (Interfaces) luôn nằm bên trong (inside) Application, trong khi adapters sẽ nằm bên ngoài (outside). Điều này rất quan trọng, các Ports được tạo ra là để phù hợp với các yêu cầu từ bên trong Application Core chứ không đơn giản là để mapping với các tools bên ngoài.

Primary or Driving Adapters

Primary or Driving Adapters sẽ hold 1 Port instnace và dùng Port đó để “tell” Application Core what to do. Nó translate (chuyển đổi) những input từ delivery mechanism để gọi đến các method trong Application Code.

4

Secondary or Driven Adapters

Ngược lại với Driving Adapters sử dụng Port, Driven Adapters implement Port và được inject vào bên trong Application Core.

5

Ví dụ, Application chúng ta cần persist data, do đó chúng ta tạo ra 1 persistance interface bao gồm các method CRUD cần thiết. Khi Application cần thực thiện persist data sẽ sử dụng itnerface này. Tiếp theo, giả sử chúng ta sẽ tạo ra 1 MySQL adapter implement persistance interface trên. Nó sẽ được inject vào và được sử dụng bởi Application core. Nếu sau này có thay đổi cần sử dụng PostgreSQL hoặc MongoDB, chúng ta chỉ cần tạo mới các adapter implement persistance interface và inject vào Application để thay thế adapter trước đó.

Inversion of control

Một đặc điểm cần chú ý về Ports & Adapters pattern là adapters sẽ phụ thuộc vào các Tool hoặc Port. Nhưng Application core chỉ phụ thuộc vào Port design để phù hợp với bussiness logic, chúng không phụ thuộc vào adapters hay tool nào.

6

Có nghĩa là hướng của sự phụ thuộc hướng về phía trung tâm lõi của ứng dụng, hay còn gọi là inversion of control principle ở architectural level.

Application Core organisation

Kiến trúc Onion sử dụng ý tưởng của Ports & Adapters Architecture và kết hợp với các layers từ Domain Driven Design.

Application Layer

Trong DDD, Application Core được chia thành Application Layer và Domain Layer. Application Layer, hay còn gọi là Use-case layer được xem như là boundary của lõi ứng dụng, tương tác với các delivery merchanisms thông qua Port. Ví dụ, trong một CMS System, có thể có các UI application sử dụng bởi user thông thường, một UI application khác được sử dụng bởi administrators và CLI UI, Web API. Những UIs (applications) này có thể gọi đến những use-case xác định bên trong Application Layer, những use-case này có thể độc lập hoặc kết hợp với những use-case khác để phù hợp với yêu cầu từ UI.

7

Application Layer bao gồm các Application Services và các Ports & Adapters interfaces (ports) như ORM interfaces, search engines interfaces, messaging interfaces, … Trong trường hợp sử dụng Command Bus and/or a Query Bus, layer này sẽ đóng vai các Handlers cho các Command và Query tới.

Các Application Services and/or Command Handlers chứa đựng logic nhằm thực hiện một use case hay một business process nào đó. Vai trò của chúng là:

  1. Sử dụng repository để tìm các entities
  2. Gọi các entities để thực thi domain logic
  3. Gọi repository để persist data.

Command Handlers có thể sử dụng với 2 cách khác nhau:

  1. Chúng có thể chứa đựng logic để thực thi use case
  2. Hoặc đơn giản là nhận các Command và kêu gọi các Application Services để thực thi logic

Application Layer còn có thể phát ra các Application Events khi thực hiện các use-case ví dụ như sending emails, notifying a 3rd party API, sending a push notification hoặc thậm chí là start một use case nằm ở một component khác của application.

Domain Layer

Đây là Layer trong cùng của Application Core. Các đối tượng trong layer này chứa đựng data và logic thuộc về business domain, chúng độc lập và không quan tâm đến business process ở layler bên ngoài.

8

Domain Services

Như đã đề cập ở trên về vai trò của Application Service:

  1. Sử dụng repository để tìm các entities
  2. Gọi các entities để thực thi domain logic
  3. Gọi repository để persist data.

Tuy nhiên, sẽ có trường hợp chúng ta thấy rằng domain logic cần invoke nhiều entity khác nhau và cảm thấy domain logic không thuộc về entity nào. Vì vậy sẽ cho domain logic lên trên tầng Application Service. Tuy nhiên nếu làm như vậy thì domain logic sẽ mất khả năng tái sử dụng trong những use case khác nhau.

Cho nên giải pháp sẽ là tạo ra Domain Service với mục đích xử lý logic với nhiều entity khác nhau. Domain Service sẽ nằm bên trong Domain Layer và không phụ thuộc vào các class ở tầng bên ngoài nó là Application Layer như Application Services hay the Repositories. Chúng chỉ có thể sử dụng các Domain Model và các Domain Services khác.

Domain Model

Ở phía trong cùng của vòng tròn Onion Architecture là Domain Model chứa các đối tượng Business Objects đại diện cho domain, nghiệp vụ. Ví dụ 1 số loại Domain Objects như Entities, Value Objects, Enums hay bất cứ object nào dùng bên trong Domain Model.

Domain Events cũng nằm Domain Model. Những events này được kích hoạt khi có sự kiện hoặc data bên trong Domain Model thay đổi. Các thành phần đăng ký lắng nghe event sẽ nhận được event tương ứng. Một ví dụ về cách sử dụng Events là Event Sourcing mà chúng ta đã tìm hiểu ở bài trước.

Components

Cho đến bây giờ, chúng ta đã phân rã kiến trúc theo hướng layers, tiếp theo hãy cùng phân tách chúng thành những nhiều khối coarse-grained mang ý nghĩa phản ánh sub-domain và bounded contexts. Theo Simon Brown trong bài viết của anh ta về “Package by component and architecturally-aligned testing“, Thường sẽ có 3 cách phân rã components là theo “Package by feature”, “Package by component” và “Package by layer”:

9

Tôi ủng hộ cách chia “Package by component” và thay đổi một chút như hình dưới đây

10

Từ mô hình layer architecture đã diễn giải từ đầu đến giờ, chúng ta có thể có thêm các đương cắt dọc qua mỗi layer để tạo ra các components. Các components có thể là Billing, User, Review hoặc Account, tuy nhiên components phải liên quan đến domain. Những Bounded contexts như Authorization and/or Authentication nên được coi như là external tools và được adapt vào system thông qua các port.

11

Decoupling the components

Cũng giống như nguyên tắc khi decoule code unit (classes, interfaces, traits, mixins, …), Những nguyên tắc chúng ta nói trước đó về low coupling and high cohesion sẽ giúp ích trong việc decouple các components.

Để decouple các class, chúng ta sử dụng Dependency Injection bằng cách truyền vào class các đối tượng đã được khởi tạo ở bên ngoài, và Dependency Inversion làm cho các class chỉ phụ thuộc vào abstractions (interfaces and/or abstract classes) chứ không phải là các class cụ thể (concrete).

Bằng cách tương tự, các components được decouple với nhau và không có sự phụ thuộc, quan hệ trực tiếp với nhau. Nói cách khác, không có bất cứ 1 đơn vị code unit nào từ component này (kể cả interface) được refer trong 1 component khác. Do đó chỉ Dependency Injection và Dependency Inversion không thì chưa đủ, đối với component, chúng ta cần thêm event, shared kernel, eventual consistency (sự toàn vẹn cuối cùng), và có thể cả discovery service!

12

Gọi logic trong component khác

Khi component B cần phải thực hiện 1 sốc công việc khi có sự thay đổi xuất phát từ component A, chúng ta không đơn giản có thể dùng các lời gọi hàm từ A đến B, vì khi đó, A sẽ phụ thuộc vào B.

Tuy nhiên, chúng ta có thể dùng event dispatcher để gửi đi 1 event đến bất cứ component nào đang đăng ký lắng nghe event đó, component B chẳng hạn, component B sau khi nhận được event sẽ thực thi logic tùy vào thông tin mà event mang tới cho nó. Điều đó có nghĩa là component A chỉ cần phụ thuộc vào event dispatcher mà không cần phụ thuộc trực tiếp vào component B.

Tuy nhiên, nếu event nằm trong component A, điều đó có nghĩa là B đã lại phải phụ thuộc vào component A. Để loại bỏ sự phụ thuộc này, chúng ta tạo ra 1 phần nhỏ, có thể gọi là 1 library chứa tập các application core functionality cần chia sẻ giữa tất cả các component và gọi đó là Shared Kernel. Điều đó có nghĩa là các component sẽ phụ thuộc vào Shared Kernel nhưng không phụ thuộc trực tiếp lẫn nhau. Shared Kernel có thể chứa các application và domain events, hoặc các Specification objects hoặc bất cứ thứ gì cần thiết phải share, tuy nhiên hãy ghi nhớ rằng cần giữ cho Shared Kernel càng nhỏ càng tốt bởi vì các thay đổi trong Shared Kernel sẽ ảnh hưởng đến phạm vực toàn hệ thống. Hơn nữa, nếu chúng ta work với một micro-services ecosystem với các component được viết bằng các ngôn ngữ khác nhau, lúc đó Shared Kernel cần phải sử dụng ngôn ngữ bất khả tri (language agnostic) để nó có thể hiểu được bởi tất cả components. Ví dụ như Event trong Shared Kernel, chúng ta có thể dùng JSON để biểu diễn các thông tin như name, properties, hoặc even methods. Bằng cách đó, các component sẽ tự diễn dịch và thực thi theo cách của chúng.

Approach này work cho cả monolithic applications và distributed applications như micro-services ecosystems. Tuy nhiên, khi event chỉ có thể được gửi bất đồng bộ (asynchronously), đối với các bối cảnh nơi kích hoạt logic trong các thành phần khác cần phải được thực hiện ngay lập tức, cách tiếp cận này sẽ không đủ! Ví dụ trong trường hợp component A cần gửi 1 HTTP request đến component B. Nếu gửi trực tiếp sẽ gây ra sự phụ thuộc (coupled). Để decoupled, chúng ta sẽ cần 1 discovery service để A có thể từ đó thông qua để gửi request. Discovery service sẽ đóng vai trò như 1 proxy đến các component liên quan và đợi kết quả trả về lại cho component yêu cầu. Lần này, component sẽ phụ thuộc vào discovery service, nhưng sẽ không phải phụ thuộc vào nhau.

Flow of control

Như đã đề cập ở trên, Flow of control đi từ User vào bên trong Application Core đến các infrastructure tools, sau đó back lại Application Core và cuối cùng response về lại user.

Theo Uncle Bob, trong bài viết của ông về Clean Architecture, tôi sẽ giải thích flow of control bằng UML diagrams

Không có Command/Query Bus

Trong trường hợp chúng ta không sử dụng command bus, Controllers sẽ phụ thuộc vào hoặc là Application Service hoặc là Query Object.

13

Trong diagram ở trên chúng ta sử dụng interface cho Application Service. Sẽ có những tranh cãi xung quanh việc có cần thiết phải sử dụng interface ở đây hay không, vì có lẽ chẳng ai sẽ “swap” với một implementation khác.

Query object sẽ chứa những câu optimized query đơn giản là trả về các raw data hiển thị cho user. Data có thể được return về dưới dạng 1 DTO để có thể inject vào ViewModel. ViewModel có thể sẽ có 1 ít logic để populate View.

Còn Application Service sẽ chứa logic để thực hiện các công việc trong system. Application Service dùng Repositories để return về các Entity(ies), sau đó sẽ gọi các logic về nghiệp vụ trong các Entities đó. Hoặc có thể gọi đến các Domain Service để coordinate một domain process với nhiều entities.

Và sau khi thực hiện xong use case, Application Service có thể notify ra các event thông qua Event Dispatcher để gửi đi các events.

Có 1 điều thú vị là chúng ta sử dụng interface trên cả persistence engine và repositories với mục đích là:

  • Persistence interface là một abstraction layer cho các ORM, vì vậy chúng ta có thể swap các ORM khác mà không làm thay đổi Application Core.
  • The repository interface slà một abstraction layer cho các persistence engine. Ví dụ chúng ta muốn switch từ MySQL sang MongoDB, repository interface có thể không đổi, và nếu chúng ta muốn tiếp tục sử dụng cùng một ORM, ngay cả persistence adapter vẫn giữ nguyên. Tuy nhiên, query language là hoàn toàn khác nhau, vì vậy chúng ta có thể tạo các repositories mới sử dụng cùng một cơ chế persistence, thực hiện cùng một repository interfaces nhưng builds các câu queries bằng MongoDB query language thay vì SQL.

Với Command/Query Bus

Trong trường hợp application của chúng ta sử dụng một Command/Query Bus, diagram của chúng ta cũng sẽ không thay đổi nhiều ngoại trừ việc Controller bây giờ sẽ phụ thuộc vào Bus và Command/Query. Nó sẽ khởi tạo Command hoặc Query và chuyển nó tới Bus, để tìm handler tương ứng nhận và xử lý command/query.

Trong diagram bên dưới, Command Handler sử dụng Application Service. Tuy nhiên, điều đó không phải lúc nào cũng cần thiết, trên thực tế trong hầu hết các trường hợp, handler sẽ chứa tất cả logic của use case. Chúng ta chỉ cần trích xuất logic từ handler thành một Application Service riêng biệt nếu chúng ta cần sử dụng lại cùng một logic đó trong một handler khác.

14

Bạn có thể nhận thấy rằng không có sự phụ thuộc giữa Bus và Command, Query cũng như các Handler. Điều này là bởi vì chúng nên, trên thực tế, không cần ý thức đến nhằm decoupling. Cách Bus biết Handler sẽ xử lý Command, hoặc Query nào, nên được thiết lập với configuration đơn thuần.

Như bạn có thể thấy, trong cả hai trường hợp tất cả các mũi tên, các phụ thuộc, mà vượt qua biên giới của application core, chúng trỏ vào bên trong. Như đã giải thích trước đây, đây là một nguyên tắc cơ bản của Ports & Adapters Architecture, Onion Architecture và Clean Architecture.

Kết luận

Mục tiêu, như mọi khi, là phải có một codebase được loosely coupled and high cohesive, để các thay đổi trở nên dễ dàng, nhanh chóng và an toàn khi thực hiện.

Plans are worthless, but planning is everything.

Eisenhower

Infographic này là một bản đồ mang tính chất khái niệm. Việc biết và hiểu tất cả các khái niệm này sẽ giúp chúng ta lên kế hoạch cho một architecture và application vững vàng.

The map is not the territory.

Alfred Korzybski

Có nghĩa là đây chỉ là guidelines! Khi rong thực tế với các use case cụ thể mà chúng ta cần áp dụng kiến thức của mình, và đó là điều sẽ xác định kiến trúc thực tế sẽ trông như thế nào!

Chúng ta cần phải hiểu tất cả các pattern này, nhưng chúng ta cũng luôn luôn cần phải suy nghĩ và hiểu chính xác những gì ứng dụng của chúng ta cần, chúng ta nên đi xa bao nhiêu trong việc decoupling and cohesiveness. Quyết định này có thể phụ thuộc vào nhiều yếu tố, bắt đầu với các yêu cầu chức năng của dự án, nhưng cũng có thể bao gồm các yếu tố như khung thời gian để xây dựng ứng dụng, tuổi thọ của ứng dụng, trải nghiệm của nhóm phát triển, v.v.

Đây là bài viết trong loạt bài viết về “Tổng quan về sự phát triển của kiến trúc phần mềm“. Đây là loạt bài viết chủ yếu giới thiệu về một số mô hình kiến trúc phần mềm hay nói đúng hơn là sự phát triển của chúng qua từng giai đoạn, qua đó giúp chúng ta có cái nhìn tổng quát, up-to-date và là roadmap để bắt đầu hành trình chinh phục (đào sâu) thế giới của những bản thiết kế với vai trò là những kỹ sư và kiến trúc sư phần mềm đam mê với nghề.

Bài viết được tham khảo từ:

https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/

Tổng hợp bởi edwardthienhoang

 

7 thoughts on “Kết hợp các mẫu kiến trúc / pattern vào trong một (DDD, Hexagonal, Onion, Clean, CQRS, …)

  1. Hi Hoàng
    Mình có chút băn khoăn về vấn đề command / query handlers. Cái này nên nằm trong domain layer chứ nhỉ :-?. Mình đọc bài viết thì đang nói nằm ở Application Layer. Nhưng nếu thiết kế DDD thì các command handler sẽ được định nghĩa theo Domain Aggerate. Bạn có thể giải thích cụ thể hơn cho mình đc không

    Thanks

    1. Hi Cường,
      Sorry vì đã reply trễ. 🙂
      Mình hiểu cách thiết kế theo hướng bạn nói, có lẽ nó phù hợp với bài viết này:
      https://edwardthienhoang.wordpress.com/2018/01/26/xay-dung-he-thong-ecommerce-voi-ddd-va-cqrs/
      Kết hợp giữa DDD và CQRS pattern.

      Trong DDD thì kiến trúc vẫn na ná như mô hình 3 layers mình hay dùng nên Domain vẫn interact trực tiếp với Data Access Layer (trong DDD là Infrastructure Layer), nên việc thiết kế CQRS pattern nằm ở Domain Layer là hoàn toàn hợp lý.

      Tuy nhiên, kiến trúc chủ đạo trong bài viết này không phải là DDD mà là Onion + Hexagonal.
      Phía trong cùng của vòng tròn Onion là Domain Model chứa các đối tượng Business Objects đại diện cho domain, nghiệp vụ, hoàn toàn không biết gì về cơ chế persistance merchanism bên ngoài.
      Việc persistance sẽ do Application Layer đảm nhận. Ở Layer này ta có Application Service và CQRS, trong bài viết có nói là dùng 1 trong 2 hoặc cả 2 cũng được, việc KẾT HỢP tất cả các pattern vào 1 architecture map thế này là chủ ý của tác giả 🙂
      Vì vậy, việc đặt Command và Query ở Application Layer theo mình cũng khá hợp lý.

      Trên mạng người ta cũng bàn tán xôn xao về vấn đề này, bạn có thể tham khảo thêm nhé:
      https://stackoverflow.com/questions/32216408/cqrs-commands-and-queries-do-they-belong-in-the-domain
      https://www.codeproject.com/Articles/555855/Introduction-to-CQRS

      Hope this help.

      1. Hi Hoàng

        Cám ơn bạn. Mình có rất nhiều thắc mắc ^^. Nếu đc hi vọng Hoàng có thể cho mình contact để đc trao đổi cũng như chia sẻ nhiều hơn không.

        Thanks

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.