Phần II: Chiến Thuật Thiết Kế (Tactical Design)

Chương 8: Các Mẫu Kiến Trúc

Layered Architecture, Ports & Adapters (Hexagonal), và Command-Query Responsibility Segregation (CQRS)
Thời gian đọc: ~32 phút
Nguyên tác: Vlad Khononov (O'Reilly)
Từ khóa: Layered Architecture, Ports & Adapters, Hexagonal, CQRS, Eventual Consistency, Architectural Slices
"Không có bất kỳ một mô hình nào có thể giải quyết hoàn hảo mọi nhu cầu của một hệ thống nghiệp vụ phức tạp. Việc tách bạch giữa mô hình ghi dữ liệu để bảo vệ tính nhất quán và các mô hình đọc dữ liệu tối ưu hóa cho truy vấn chính là chìa khóa mở ra sự tự do và hiệu năng tột bậc." — Greg Young, Tác giả sáng lập mẫu kiến trúc CQRS

Giới Thiệu: Mẫu Kiến Trúc (Architectural Patterns)

Các mẫu thiết kế chiến thuật (tactical patterns) được thảo luận từ đầu Phần II đến nay — bao gồm Transaction Script, Active Record, Domain Model, và Event-Sourced Domain Model — đã định nghĩa các cách thức khác nhau để mô hình hóa và triển khai logic nghiệp vụ.

Các Mẫu Kiến Trúc (Architectural Patterns) đưa ra các nguyên tắc tổ chức cấu trúc cho toàn bộ mã nguồn (codebase). Chúng quy định việc phân chia trách nhiệm giữa các thành phần phần mềm, định hình các ranh giới trừu tượng, và thiết lập các quy tắc chi phối sự tương tác cũng như chiều phụ thuộc giữa các khối cấu thành hệ thống.

Trong chương này, chúng ta sẽ khảo sát và so sánh 3 mẫu kiến trúc kinh điển, nắm rõ bản chất cơ học, sự tương thích của chúng với các mẫu logic nghiệp vụ, và cách áp dụng linh hoạt trong thực tế:

  • Kiến trúc Phân tầng (Layered Architecture): Mô hình phân rã mã nguồn truyền thống theo các mối quan tâm công nghệ (technological concerns).
  • Kiến trúc Cổng & Bộ điều hợp (Ports & Adapters / Hexagonal Architecture): Mô hình đặt logic nghiệp vụ làm trung tâm, đảo ngược hoàn toàn sự phụ thuộc với hạ tầng kỹ thuật.
  • Phân tách Trách nhiệm Lệnh và Truy vấn (CQRS - Command-Query Responsibility Segregation): Mô hình cho phép đại diện dữ liệu hệ thống dưới nhiều góc nhìn lưu trữ khác nhau, tách rời luồng ghi (Command) và luồng đọc (Query).

Kiến Trúc Phân Tầng (Layered Architecture)

Kiến trúc Phân tầng (Layered Architecture) là một trong những mẫu kiến trúc phổ biến và lâu đời nhất trong ngành kỹ thuật phần mềm. Nó phân rã cơ sở mã thành các tầng nằm ngang (horizontal layers), trong đó mỗi tầng chịu trách nhiệm cho một nhóm mối quan tâm kỹ thuật cụ thể.

Kiến trúc phân tầng truyền thống
Hình 8-1. Kiến trúc phân tầng kinh điển: Tầng Trình Diễn → Tầng Logic Nghiệp Vụ → Tầng Truy Cập Dữ Liệu

Ba Tầng Cốt Lõi Của Hệ Thống

Theo phân loại kinh điển của Eric Evans trong cuốn sách Domain-Driven Design (2003), kiến trúc phân tầng bao gồm 3 tầng nền tảng:

1. Tầng Trình Diễn (Presentation Layer)

Tầng trình diễn
Hình 8-2. Tầng Trình Diễn (Presentation Layer)

Tầng này đóng vai trò giao diện tiếp xúc trực tiếp giữa chương trình và thế giới bên ngoài. Nó là nơi người dùng hoặc các hệ thống khác tương tác với ứng dụng. Trách nhiệm chính bao gồm:

  • Giao diện đồ họa người dùng (Graphical User Interface - GUI: Web pages, Single Page Apps, Desktop Apps).
  • Giao diện dòng lệnh (Command-Line Interface - CLI).
  • Các điểm cuối lập trình (API: RESTful endpoints, GraphQL, gRPC).
  • Lắng nghe và tiêu thụ thông điệp từ Message Broker (Message Consumer).

Về cốt lõi, tầng trình diễn nhận dữ liệu đầu vào từ người dùng hoặc hệ thống ngoài, chuyển đổi nó thành định dạng nội bộ, kích hoạt các thao tác tương ứng ở tầng dưới, và đóng gói kết quả phản hồi lại cho người gọi.

2. Tầng Logic Nghiệp Vụ (Business Logic Layer)

Tầng logic nghiệp vụ
Hình 8-3. Tầng Logic Nghiệp Vụ (Business Logic Layer)

Như tên gọi của nó, tầng này chứa toàn bộ các phép tính toán, quy tắc nghiệp vụ, quy trình vận hành và kiểm tra tính hợp lệ dữ liệu. Đây chính là trái tim của phần mềm — nơi hiện thực hóa một trong 4 mẫu logic nghiệp vụ mà chúng ta đã học: Transaction Script, Active Record, Domain Model, hoặc Event-Sourced Domain Model.

3. Tầng Truy Cập Dữ Liệu (Data Access Layer - DAL)

Tầng truy cập dữ liệu
Hình 8-4. Tầng Truy Cập Dữ Liệu (Data Access Layer)

Tầng này cung cấp khả năng lưu trữ bền vững (persistence) cho dữ liệu hệ thống. Nó chịu trách nhiệm giao tiếp với cơ sở dữ liệu quan hệ (RDBMS), cơ sở dữ liệu NoSQL, kho lưu trữ tài liệu, lưu trữ đám mây (AWS S3, Google Cloud Storage), hoặc hệ thống tập tin nội bộ.

Mối Quan Hệ Giữa Các Tầng: Chiều Phụ Thuộc Hướng Xuống

Trong mô hình phân tầng nguyên bản, sự phụ thuộc luôn chảy theo chiều từ trên xuống dưới (top-down dependency):

  • Tầng Trình Diễn phụ thuộc vào Tầng Logic Nghiệp Vụ.
  • Tầng Logic Nghiệp Vụ phụ thuộc vào Tầng Truy Cập Dữ Liệu.
Mối quan hệ phân tầng nghiêm ngặt vs nới lỏng
Hình 8-5. Chiều phụ thuộc trong kiến trúc phân tầng: Tầng nghiệp vụ tham chiếu tầng dữ liệu

Có hai biến thể về mức độ kiểm soát phụ thuộc:

  • Phân tầng Nghiêm ngặt (Strict Layering): Một tầng chỉ được phép giao tiếp với tầng nằm ngay bên dưới nó. Tầng Trình Diễn không được phép gọi trực tiếp Tầng Truy Cập Dữ Liệu mà bắt buộc phải thông qua Tầng Logic Nghiệp Vụ.
  • Phân tầng Nới lỏng (Relaxed / Open Layering): Một tầng có thể bỏ qua tầng trung gian để gọi trực tiếp các tầng thấp hơn (ví dụ: Tầng Trình Diễn gọi trực tiếp Tầng Truy Cập Dữ Liệu để đọc dữ liệu báo cáo dạng chỉ-đọc nhằm tối ưu hóa hiệu năng).

Biến Thể Mở Rộng: Tầng Dịch Vụ (Service Layer)

Trong thực tế, người ta thường bổ sung thêm một tầng thứ tư nằm giữa Tầng Trình Diễn và Tầng Logic Nghiệp Vụ: Tầng Dịch Vụ (Service Layer).

Kiến trúc có Tầng Dịch Vụ (Service Layer)
Hình 8-6. Tầng Dịch Vụ đóng vai trò ranh giới mặt tiền (Façade) điều phối nghiệp vụ
"Tầng dịch vụ định nghĩa ranh giới của một ứng dụng bằng một lớp các dịch vụ; nó thiết lập tập hợp các thao tác khả dụng và điều phối phản ứng của ứng dụng trong từng thao tác." — Martin Fowler, "Patterns of Enterprise Application Architecture"

Để thấy rõ sự cần thiết của Service Layer, hãy xem xét đoạn mã Controller trong một ứng dụng MVC khi chưa có Service Layer:

UserController.cs (Presentation Layer ôm đồm điều phối)
namespace MvcApplication.Controllers
{
    public class UserController : Controller
    {
        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Create(ContactDetails contactDetails)
        {
            OperationResult result = null;
            try
            {
                _db.StartTransaction();
                var user = new User();
                user.SetContactDetails(contactDetails);
                user.Save();
                _db.Commit();
                result = OperationResult.Success;
            }
            catch (Exception ex)
            {
                _db.Rollback();
                result = OperationResult.Exception(ex);
            }
            return View(result);
        }
    }
}

Trong ví dụ trên, Controller ở Tầng Trình Diễn đang phải gánh vác việc mở Transaction CSDL, khởi tạo Active Record User, gọi lưu và Rollback khi có lỗi. Nếu ngày mai chúng ta muốn mở thêm một Web API hoặc một CLI Tool, đoạn logic điều phối giao dịch này sẽ bị sao chép nhân bản ở khắp nơi!

Bằng cách tái cấu trúc và trích xuất logic điều phối vào UserService (Service Layer), chúng ta đạt được sự đóng gói hoàn hảo:

UserService.cs & UserController.cs (Tách bạch Service Layer)
namespace ServiceLayer
{
    public class UserService
    {
        public OperationResult Create(ContactDetails contactDetails)
        {
            OperationResult result = null;
            try
            {
                _db.StartTransaction();
                var user = new User();
                user.SetContactDetails(contactDetails);
                user.Save();
                _db.Commit();
                result = OperationResult.Success;
            }
            catch (Exception ex)
            {
                _db.Rollback();
                result = OperationResult.Exception(ex);
            }
            return result;
        }
    }
}

namespace MvcApplication.Controllers
{
    public class UserController : Controller
    {
        private UserService _userService;

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Create(ContactDetails contactDetails)
        {
            var result = _userService.Create(contactDetails);
            return View(result);
        }
    }
}
Lợi Ích Của Tầng Dịch Vụ (Service Layer)
  • Tái sử dụng: Phục vụ cùng một lúc nhiều giao diện người dùng (GUI Web, Mobile App, REST API, CLI) mà không trùng lặp code điều phối.
  • Tính mô-đun hóa: Gom toàn bộ các ca sử dụng (Use Cases) của hệ thống vào một nơi tập trung rõ ràng.
  • Dễ kiểm thử: Kiểm thử toàn bộ luồng nghiệp vụ từ Service Layer mà không cần khởi động HTTP Context giả lập của Controller.

Phân Biệt: Layers (Tầng Logic) vs Tiers (Tầng Vật Lý)

Nhiều kỹ sư thường nhầm lẫn giữa khái niệm Layer và Tier (hệ thống N-Tier):

Hệ thống N-Tier
Hình 8-7. Hệ thống N-Tier: Phân tách ranh giới vật lý trên các máy chủ khác nhau
Tiêu chí so sánh Layer (Tầng Logic) Tier (Tầng Vật Lý)
Bản chất Ranh giới logic bên trong cấu trúc mã nguồn. Ranh giới vật lý giữa các máy chủ/tiến trình (machines / processes).
Vòng đời Cùng biên dịch, cùng đóng gói và triển khai chung trong một ứng dụng duy nhất. Được triển khai độc lập trên các phần cứng vật lý hoặc cụm máy chủ riêng biệt.
Giao tiếp Gọi hàm trực tiếp trong bộ nhớ (In-memory function call) — độ trễ micro-giây. Giao tiếp qua mạng (Network / RPC / HTTP) — có độ trễ mạng và rủi ro đứt kết nối.

Khi Nào Nên Áp Dụng Kiến Trúc Phân Tầng?

Do chiều phụ thuộc hướng xuống (Business Logic Layer phụ thuộc trực tiếp vào Data Access Layer), kiến trúc phân tầng là một sự kết hợp hoàn hảo cho hệ thống triển khai bằng Transaction Script hoặc Active Record.

Tại Sao Kiến Trúc Phân Tầng Gây Khó Khăn Cho Domain Model?

Trong Domain Model, các thực thể nghiệp vụ (Aggregates, Entities, Value Objects) phải hoàn toàn thuần khiết, tuyệt đối không được phụ thuộc và không được biết gì về cơ sở dữ liệu bên dưới (Persistence Ignorance). Nếu áp dụng kiến trúc phân tầng truyền thống, Domain Layer lại tham chiếu trực tiếp đến Data Access Layer — điều này biến Domain Model thành nô lệ của cấu trúc CSDL và vi phạm nguyên lý cốt lõi của DDD. Để giải quyết triệt để vấn đề này, chúng ta cần một mẫu kiến trúc tân tiến hơn: Ports & Adapters.

Kiến Trúc Cổng & Bộ Điều Hợp (Ports & Adapters)

Kiến trúc Cổng và Bộ điều hợp (Ports & Adapters) — còn được gọi rộng rãi là Kiến trúc Lục giác (Hexagonal Architecture) — giải quyết triệt để điểm yếu chí tử của kiến trúc phân tầng bằng cách áp dụng Nguyên lý Đảo ngược Phụ thuộc (Dependency Inversion Principle - DIP).

Tái Cấu Trúc Bằng Nguyên Lý Đảo Ngược Phụ Thuộc (DIP)

Hãy xem quá trình chuyển đổi kỳ diệu từ Kiến trúc Phân tầng sang Ports & Adapters qua 3 bước:

Bước 1: Hợp nhất các mối quan tâm kỹ thuật vào Tầng Hạ Tầng (Infrastructure Layer). Cả Tầng Trình Diễn (Web, API) và Tầng Truy Cập Dữ Liệu (SQL, Cache, File) thực chất đều chỉ là các chi tiết giao tiếp với thế giới công nghệ bên ngoài. Do đó, ta gom chúng lại thành một Tầng Hạ Tầng duy nhất (Hình 8-8).

Hợp nhất thành tầng hạ tầng
Hình 8-8. Hợp nhất Tầng Trình Diễn và Tầng Truy Cập Dữ Liệu thành Tầng Hạ Tầng (Infrastructure Layer)

Bước 2: Đảo ngược chiều phụ thuộc (Dependency Inversion). Nguyên lý DIP khẳng định: "Các module cấp cao (Logic Nghiệp vụ) không được phụ thuộc vào các module cấp thấp (Hạ tầng). Cả hai phải cùng phụ thuộc vào các trừu tượng (Abstractions)." Vì vậy, ta đảo ngược hoàn toàn mũi tên: Tầng Hạ Tầng phải phụ thuộc vào Tầng Logic Nghiệp Vụ (Hình 8-9).

Đảo ngược sự phụ thuộc
Hình 8-9. Đảo ngược phụ thuộc: Tầng Nghiệp Vụ trở thành trung tâm, không phụ thuộc vào hạ tầng

Bước 3: Bổ sung Tầng Ứng Dụng (Application Layer). Thêm một lớp mặt tiền điều phối bên ngoài tầng nghiệp vụ, tạo nên cấu trúc 3 lớp đồng tâm hoàn chỉnh (Hình 8-10).

Cấu trúc các tầng của Ports and Adapters
Hình 8-10. Các tầng của Kiến trúc Ports & Adapters: Infrastructure → Application → Business Logic

Cơ Chế Hoạt Động: Ports (Cổng) và Adapters (Bộ Điều Hợp)

Tại sao mô hình này lại mang tên Ports & Adapters? Hãy quan sát cấu trúc tương tác trong Hình 8-11:

Kiến trúc Ports & Adapters
Hình 8-11. Kiến trúc Ports & Adapters: Lõi nghiệp vụ định nghĩa các Ports; Tầng hạ tầng cung cấp các Adapters tương ứng
  • Port (Cổng): Là các Interface trừu tượng do Tầng Nghiệp Vụ (hoặc Tầng Ứng Dụng) định nghĩa. Port mô tả những nhu cầu giao tiếp mà lõi nghiệp vụ cần để hoàn thành công việc, nhưng hoàn toàn không chứa bất kỳ mã công nghệ cụ thể nào.
  • Adapter (Bộ điều hợp): Là các lớp triển khai cụ thể (concrete classes) nằm ở Tầng Hạ Tầng, thực thi các Interface của Port để làm việc với các công nghệ thực tế (SQL Server, MongoDB, RabbitMQ, SendGrid, REST...).
Định nghĩa Port (C# Interface) & Adapter (Concrete Class)
// PORT: Nằm trong Tầng Nghiệp Vụ (hoàn toàn thuần khiết)
namespace App.BusinessLogicLayer
{
    public interface IMessaging
    {
        void Publish(Message payload);
        void Subscribe(Message type, Action callback);
    }
}

// ADAPTER: Nằm trong Tầng Hạ Tầng (triển khai AWS SQS cụ thể)
namespace App.Infrastructure.Adapters
{
    public class SQSBus : IMessaging
    {
        public void Publish(Message payload)
        {
            // Gọi Amazon SQS SDK thực tế
        }

        public void Subscribe(Message type, Action callback)
        {
            // Lắng nghe tin nhắn từ hàng đợi AWS SQS
        }
    }
}

Nhờ có kỹ thuật Dependency Injection (IoC Container), khi ứng dụng khởi chạy (bootstrapping), bộ điều hợp SQSBus sẽ được tự động tiêm vào cổng IMessaging mà logic nghiệp vụ không hề hay biết công nghệ bên dưới là gì!

Các Biến Thể Tương Đương: Hexagonal, Onion, và Clean Architecture

Trong văn bản và cộng đồng phát triển phần mềm, bạn sẽ thường xuyên nghe thấy các tên gọi khác nhau:

Tên Mẫu Kiến Trúc Tác Giả & Năm Tên Gọi Lớp Tương Đương
Ports & Adapters (Hexagonal) Alistair Cockburn (2005) Application Core, Ports & Adapters
Onion Architecture Jeffrey Palermo (2008) Domain Model, Domain Services, Application Services, Infrastructure
Clean Architecture Robert C. Martin (Uncle Bob - 2012) Entities, Use Cases, Interface Adapters, Frameworks & Drivers

Tác giả Vlad Khononov lưu ý: Tất cả các mẫu trên đều dựa trên cùng một triết lý thiết kế duy nhất: đặt Logic Nghiệp Vụ ở trung tâm và đảo ngược sự phụ thuộc của hạ tầng! Sự khác biệt chỉ nằm ở hệ thống thuật ngữ.

Khi Nào Nên Áp Dụng Ports & Adapters?

Việc tách bạch hoàn toàn logic nghiệp vụ khỏi mọi chi tiết công nghệ khiến Ports & Adapters trở thành sự lựa chọn hoàn hảo nhất cho Domain Model và Event-Sourced Domain Model.

Command-Query Responsibility Segregation (CQRS)

Mẫu kiến trúc CQRS (Phân tách Trách nhiệm Lệnh và Truy vấn) được xây dựng trên cùng các nguyên tắc tổ chức của Ports & Adapters, nhưng bổ sung một đột phá mang tính cách mạng trong cách thức quản trị dữ liệu của hệ thống: nó cho phép biểu diễn dữ liệu của cùng một thực thể dưới nhiều mô hình lưu trữ lâu bền khác nhau.

Mô Hình Hóa Đa Ngôn Ngữ Dữ Liệu (Polyglot Modeling & Persistence)

Trong hầu hết các hệ thống thực tế, việc ép buộc một mô hình dữ liệu duy nhất phục vụ cho tất cả các nhu cầu là một điều bất khả thi:

  • Xử lý giao dịch trực tuyến (OLTP): Cần dữ liệu chuẩn hóa cao độ (3rd Normal Form), khóa ngoại, ràng buộc chặt chẽ để thực thi giao dịch ACID và bảo vệ bất biến.
  • Xử lý phân tích & Báo cáo (OLAP): Cần dữ liệu phi chuẩn hóa (denormalized), cấu trúc dạng cột (columnar) để tính toán tổng hợp cực nhanh.
  • Tìm kiếm người dùng: Cần máy chủ chỉ mục văn bản (Elasticsearch / OpenSearch).
"Mọi Cơ Sở Dữ Liệu Đều Có Khuyết Tật" — Greg Young

Không có cơ sở dữ liệu nào hoàn hảo cho mọi tác vụ! Thay vì cố công tìm kiếm một hệ quản trị CSDL thần thánh, kiến trúc Polyglot Persistence sử dụng kết hợp nhiều loại cơ sở dữ liệu chuyên biệt: RDBMS cho giao dịch, Document store cho linh hoạt, và Search engine cho tìm kiếm. CQRS chính là cỗ máy kiến trúc điều phối sự đồng bộ hoàn hảo giữa các kho dữ liệu đa dạng này.

Mô Hình Lệnh (Command) vs Mô Hình Đọc (Read Models)

Đúng như tên gọi của nó, CQRS phân tách rạch ròi trách nhiệm của các mô hình thành 2 nhóm chuyên biệt (Hình 8-12):

Kiến trúc CQRS
Hình 8-12. Kiến trúc CQRS: Phân tách rạch ròi giữa Mô Hình Thực Thi Lệnh và các Mô Hình Đọc (Read Models)
Đặc tính Mô hình Thực thi Lệnh (Command Model) Mô hình Đọc (Read Models / Projections)
Số lượng Duy nhất 1 mô hình trong một Bounded Context. Không giới hạn số lượng; tùy ý tạo bao nhiêu mô hình tùy thích.
Mục đích Thực thi nghiệp vụ, kiểm tra tính hợp lệ và bảo vệ invariants. Trình diễn dữ liệu cho người dùng, cấp dữ liệu cho hệ thống ngoài.
Tính nhất quán Nhất quán mạnh (Strong consistency) — là Nguồn Chân lý (Source of Truth). Nhất quán sau (Eventual consistency) — là các bản chiếu precached.
Thao tác dữ liệu Có thể ghi, sửa đổi và kiểm soát tương tranh lạc quan. Chỉ đọc (Read-only); tuyệt đối cấm sửa đổi trực tiếp dữ liệu mô hình đọc.
Tái sinh Dữ liệu gốc, không bao giờ được xóa tùy tiện. Có thể xóa trắng hoàn toàn và chạy lại phép chiếu để tái sinh 100%.

Cơ Chế Chiếu Mô Hình Đọc: Đồng Bộ vs Bất Đồng Bộ

Làm thế nào để các thay đổi từ Mô hình Lệnh được truyền tải sang các Mô hình Đọc? Có hai cơ chế chính:

1. Phép Chiếu Đồng Bộ (Synchronous Projections)

Trong mô hình đồng bộ, việc cập nhật mô hình đọc diễn ra ngay tại thời điểm lệnh được xử lý hoặc thông qua cơ chế Catch-up Subscription:

Mô hình chiếu đồng bộ
Hình 8-13. Phép chiếu đồng bộ (Synchronous projection model)
Chiếu đồng bộ qua catch-up subscription
Hình 8-14. Chiếu đồng bộ các mô hình đọc thông qua catch-up subscription

Để triển khai Catch-up Subscription trên cơ sở dữ liệu quan hệ, bảng dữ liệu thường có một cột số tăng tự động (Checkpoint) để bộ đọc biết chính xác bản ghi nào mới được thêm vào (Hình 8-15):

Cột Checkpoint tự động tăng
Hình 8-15. Cột Checkpoint tự sinh trong cơ sở dữ liệu quan hệ (ví dụ: SQL Server rowversion / sequence)
  • Ưu điểm: Tính nhất quán mạnh — người dùng gửi lệnh xong đọc lại thấy ngay kết quả mới.
  • Nhược điểm: Làm giảm tốc độ ghi do phải cập nhật nhiều bảng/kho dữ liệu trong cùng luồng xử lý.

2. Phép Chiếu Bất Đồng Bộ (Asynchronous Projections)

Mô hình Lệnh xuất các Domain Event ra một Message Broker (Kafka, RabbitMQ, AWS EventBridge). Các tiến trình xử lý nền (Projection Engines) độc lập tiêu thụ sự kiện và cập nhật vào các kho lưu trữ tương ứng (Hình 8-16):

Mô hình chiếu bất đồng bộ
Hình 8-16. Phép chiếu bất đồng bộ của các mô hình đọc qua Message Bus
Thách Thức Của Phép Chiếu Bất Đồng Bộ

Mặc dù có khả năng mở rộng vô hạn và tốc độ ghi cực nhanh, phép chiếu bất đồng bộ tiềm ẩn đầy rẫy các rủi ro của hệ thống phân tán:

  • Trùng lặp tin nhắn hoặc tin nhắn đến sai thứ tự: Có thể làm sai lệch dữ liệu chiếu.
  • Tính nhất quán sau (Eventual Consistency): Người dùng vừa sửa dữ liệu, bấm F5 vẫn thấy dữ liệu cũ, dẫn đến trải nghiệm hoang mang.

Lời khuyên của Vlad Khononov: Luôn ưu tiên triển khai Synchronous Projection trước cho các nhu cầu cốt lõi. Chỉ bổ sung thêm Asynchronous Projection cho các tác vụ báo cáo, phân tích hoặc khi hệ thống thực sự chịu tải lớn đòi hỏi mở rộng quy mô.

Hiểu Lầm Phổ Biến Về CQRS: "Command Không Được Trả Về Dữ Liệu"?

Một ngộ nhận tai hại thường gặp khi kỹ sư tiếp cận CQRS là: "Lệnh (Command) chỉ được sửa đổi dữ liệu và phải trả về void. Muốn hiển thị dữ liệu thì client bắt buộc phải gọi riêng một Query qua Read Model."

Tác giả khẳng định: Quan niệm đó hoàn toàn SAI LẦM và dẫn đến trải nghiệm người dùng tồi tệ!

Một Command luôn luôn nên thông báo cho người gọi biết nó thành công hay thất bại. Nếu thất bại, nguyên nhân cụ thể là gì? Có lỗi xác thực nào không? Hơn thế nữa, Command hoàn toàn có thể — và trong nhiều trường hợp RẤT NÊN — trả về dữ liệu vừa tạo hoặc cập nhật (ví dụ: mã ID bản ghi vừa sinh, trạng thái mới) để UI có thể hiển thị tức thì cho người dùng.

Quy Tắc Vàng Khi Trả Dữ Liệu Từ Command

Dữ liệu được trả về trực tiếp từ phương thức xử lý Command bắt buộc phải xuất phát từ Mô hình Lệnh (Command Execution Model) — nơi có tính nhất quán mạnh. Tuyệt đối không được truy vấn từ Mô hình Đọc (Read Model) vì mô hình đọc có thể chưa kịp cập nhật do độ trễ nhất quán sau.

Khi Nào Nên Áp Dụng CQRS?

CQRS đặc biệt giá trị cho các ứng dụng cần biểu diễn cùng một dữ liệu trong nhiều kho lưu trữ chuyên biệt (Polyglot Persistence). Và đặc biệt, CQRS là mẫu thiết kế BẮT BUỘC phải có đối với các hệ thống xây dựng theo mô hình Event-Sourced Domain Model (vì Event Store chỉ cho phép nạp sự kiện theo từng ID của một thực thể, không thể nào thực hiện các câu truy vấn phức tạp nếu không chiếu ra các Read Models).

Phạm Vi & Lát Cắt Kiến Trúc (Architectural Slices)

Một sai lầm phổ biến khác trong quản trị kiến trúc là áp đặt duy nhất một mẫu kiến trúc cho toàn bộ một Bounded Context hoặc toàn bộ doanh nghiệp.

Hãy nhớ lại: một Bounded Context có thể bao bọc nhiều Subdomain khác nhau (Core, Supporting, Generic) như minh họa trong Hình 8-17:

Bounded Context trải rộng qua nhiều Subdomain
Hình 8-17. Một Bounded Context có thể bao gồm nhiều Subdomain với bản chất nghiệp vụ hoàn toàn khác nhau

Nếu ép buộc một kiến trúc phức tạp như CQRS và Ports & Adapters cho một Subdomain hỗ trợ dạng CRUD đơn giản, bạn đang tự tạo ra độ phức tạp ngẫu nhiên (Accidental Complexity). Ngược lại, nếu dùng kiến trúc phân tầng đơn giản cho Subdomain cốt lõi thì hệ thống sẽ nhanh chóng suy thoái thành một "Đống bùn lớn" (Big Ball of Mud).

Lát Cắt Kiến Trúc (Architectural Slices)

Giải pháp tối ưu là phân chia hệ thống theo chiều dọc (Vertical Slices) kết hợp với phân chia theo chiều ngang (Horizontal Layers), như minh họa trong Hình 8-18:

Các lát cắt kiến trúc
Hình 8-18. Các Lát Cắt Kiến Trúc: Áp dụng các mẫu kiến trúc và logic nghiệp vụ khác nhau cho từng phân khu nghiệp vụ cụ thể

Trong cùng một Bounded Context:

  • Phân khu Core Subdomain có thể được xây dựng bằng Ports & Adapters + Event-Sourced Domain Model + CQRS.
  • Phân khu Supporting Subdomain có thể chỉ cần Layered Architecture + Active Record.
  • Phân khu Generic Subdomain có thể dùng Transaction Script hoặc tích hợp phần mềm có sẵn của bên thứ ba.

Tổng Kết Chương

Chương này đã mang lại bức tranh toàn cảnh về cách tổ chức cơ sở mã để hỗ trợ các mẫu triển khai logic nghiệp vụ:

Mẫu Kiến Trúc Mối Quan Tâm Trung Tâm Chiều Phụ Thuộc Mẫu Logic Nghiệp Vụ Phù Hợp
Kiến Trúc Phân Tầng (Layered) Phân tách theo các mối quan tâm công nghệ (UI, Logic, DB). Từ trên xuống dưới (Logic phụ thuộc vào Data Access). Active Record, Transaction Script.
Cổng & Bộ Điều Hợp (Ports & Adapters) Độc lập hoàn toàn với hạ tầng kỹ thuật. Đảo ngược (Hạ tầng phụ thuộc vào Logic Nghiệp Vụ). Domain Model, Event-Sourced Domain Model.
CQRS Đại diện dữ liệu dưới nhiều mô hình tối ưu riêng cho Ghi và Đọc. Tương tự Ports & Adapters, nhưng tách rạch ròi Command và Query. Bắt buộc cho Event-Sourcing, phù hợp cho mọi hệ thống cần Polyglot Persistence.

Trong chương tiếp theo — Chương 9: Các Mẫu Giao Tiếp (Communication Patterns) — chúng ta sẽ tìm hiểu cách kết nối và chuyển tải thông tin an toàn giữa các thành phần phân tán thông qua Outbox Pattern, Saga, và Process Manager!

Bài Tập Ôn Tập & Thảo Luận Nghiệp Vụ

Dưới đây là 5 câu hỏi ôn tập kèm lời giải chi tiết và trích dẫn chuẩn xác từ tác giả Vlad Khononov (Appendix B).

Câu 1: Mẫu kiến trúc nào trong số các mẫu đã thảo luận có thể được sử dụng khi logic nghiệp vụ được triển khai bằng mẫu Active Record?
A. Kiến trúc phân tầng (Layered architecture)
B. Ports & adapters
C. CQRS
D. Cả A và C
Đáp án chính xác: D (Cả A và C).
Giải thích chi tiết (Appendix B): Active Record kết hợp chặt chẽ cấu trúc bảng dữ liệu với logic nghiệp vụ, do đó nó tương thích hoàn hảo với Kiến trúc phân tầng (A). Bên cạnh đó, CQRS (C) cũng hoàn toàn có thể kết hợp với Active Record: bạn sử dụng Active Record cho Mô hình Thực thi Lệnh (Command Execution Model), và chiếu dữ liệu ra các Read Models tối ưu cho việc truy vấn. Ngược lại, Ports & Adapters (B) yêu cầu logic nghiệp vụ phải độc lập với chi tiết lưu trữ (Persistence Ignorance), điều mà Active Record không đáp ứng được.
Câu 2: Mẫu kiến trúc nào giúp tách rời (decouple) logic nghiệp vụ khỏi các mối quan tâm hạ tầng kỹ thuật?
A. Kiến trúc phân tầng (Layered architecture)
B. Ports & adapters
C. CQRS
D. Cả B và C
Đáp án chính xác: D (Cả B và C).
Giải thích chi tiết (Appendix B): Cả Ports & Adapters và CQRS đều áp dụng Nguyên lý Đảo ngược Phụ thuộc (DIP) để đặt logic nghiệp vụ ở trung tâm và trừu tượng hóa các kết nối hạ tầng thành các Port/Interface. Kiến trúc phân tầng truyền thống (A) thì ngược lại: nó khiến tầng nghiệp vụ phụ thuộc trực tiếp vào tầng truy cập dữ liệu.
Câu 3: Giả sử bạn đang triển khai mẫu Ports & Adapters và cần tích hợp một Message Bus được quản lý bởi nhà cung cấp đám mây (ví dụ AWS SQS hoặc Azure Service Bus). Mã tích hợp cụ thể này nên được đặt ở tầng nào?
A. Tầng logic nghiệp vụ (Business logic layer)
B. Tầng ứng dụng (Application layer)
C. Tầng hạ tầng (Infrastructure layer)
D. Bất kỳ tầng nào
Đáp án chính xác: C (Tầng hạ tầng - Infrastructure layer).
Giải thích chi tiết (Appendix B): Trong Ports & Adapters, tất cả các tích hợp công nghệ cụ thể (kể cả SDK của bên thứ ba, database driver, hay cloud message bus) đều là các bộ điều hợp (Adapters) và bắt buộc phải nằm ở Tầng Hạ Tầng. Tầng nghiệp vụ chỉ chứa định nghĩa trừu tượng (Port Interface), ví dụ IMessagingBus.
Câu 4: Phát biểu nào sau đây là ĐÚNG về mẫu kiến trúc CQRS?
A. Phép chiếu bất đồng bộ (Asynchronous projections) dễ mở rộng quy mô hơn.
B. Chỉ có thể sử dụng hoặc phép chiếu đồng bộ, hoặc phép chiếu bất đồng bộ, chứ không thể dùng cả hai cùng lúc.
C. Một Command không bao giờ được phép trả về bất kỳ thông tin nào cho người gọi.
D. Một Command có thể trả về thông tin miễn là thông tin đó bắt nguồn từ mô hình nhất quán mạnh (Command Execution Model).
E. Cả A và D đều đúng.
Đáp án chính xác: E (Cả A và D).
Giải thích chi tiết (Appendix B): Phép chiếu bất đồng bộ giải phóng luồng ghi khỏi việc cập nhật read models ngay lập tức nên dễ mở rộng quy mô hơn rất nhiều (A đúng). Và Command hoàn toàn có thể trả về dữ liệu (như kết quả thực thi, mã ID mới) miễn là dữ liệu đó đến từ mô hình nhất quán mạnh (D đúng). B sai vì bạn có thể kết hợp cả hai cơ chế chiếu; C sai vì cấm command trả dữ liệu là một ngộ nhận gây hại.
Câu 5 (Thảo luận chuyên sâu): Mẫu CQRS cho phép biểu diễn cùng một đối tượng nghiệp vụ dưới nhiều mô hình lưu trữ bền vững khác nhau, và do đó cho phép làm việc với nhiều mô hình trong cùng một Bounded Context. Điều này có mâu thuẫn với định nghĩa cốt lõi của Bounded Context là "một ranh giới của một mô hình" hay không?
Câu trả lời chính thức từ Vlad Khononov (Appendix B):
KHÔNG hề mâu thuẫn!

Việc làm việc với nhiều mô hình được chiếu ra bởi mẫu CQRS không hề xung đột với định nghĩa Bounded Context là một ranh giới mô hình. Bởi vì:
  • Trong số tất cả các mô hình đó, chỉ có duy nhất một mô hình được định nghĩa là Nguồn Chân lý (Source of Truth) — đó là Mô hình Thực thi Lệnh (Command Execution Model), nơi duy nhất chịu trách nhiệm bảo vệ các bất biến và thực hiện các thay đổi đối với trạng thái của Aggregate.
  • Tất cả các mô hình còn lại chỉ là các phép chiếu phái sinh (projections) chỉ-đọc được suy diễn từ mô hình chân lý đó nhằm phục vụ việc hiển thị tối ưu cho người dùng. Ranh giới Ubiquitous Language và tính nhất quán ngữ nghĩa vẫn được bảo toàn nguyên vẹn trong Bounded Context.
Chương trước ← Chương 7: Mô Hình Hóa Chiều Thời Gian
Chương tiếp theo Chương 9: Các Mẫu Giao Tiếp (Communication Patterns) →