Phần II: Thiết Kế Chiến Thuật • Chương 6

Xử Lý Logic Nghiệp Vụ Phức Tạp

Tackling Complex Business Logic • Mẫu hình Mô Hình Miền (Domain Model) và các viên gạch nền tảng: Value Objects, Entities, Aggregates, Domain Events & Domain Services

Thời gian đọc ước tính: 28 phút
Trọng tâm: Domain Model, Value Objects, Aggregates, Consistency Boundaries, Domain Events

Chương trước đã phân tích hai mẫu hình xử lý các trường hợp logic nghiệp vụ tương đối đơn giản: Transaction Script và Active Record. Chương này tiếp tục chủ đề cài đặt logic nghiệp vụ nhưng nâng cấp lên một cấp độ hoàn toàn mới: giới thiệu mẫu thiết kế hướng tới giải quyết các miền nghiệp vụ có độ phức tạp cao — Mẫu Mô Hình Miền (Domain Model Pattern).

1. Lịch Sử Hình Thành (History)

Cũng giống như Transaction Script và Active Record, mẫu hình Domain Model lần đầu tiên được Martin Fowler đúc kết và đặt tên trong cuốn sách danh tiếng Patterns of Enterprise Application Architecture (2002). Trong phần kết luận của chương viết về mẫu hình này, Fowler đã đưa ra một lời giới thiệu mang tính lịch sử: "Eric Evans hiện đang viết một cuốn sách chuyên sâu về việc xây dựng các Domain Models."

Cuốn sách được dẫn chiếu đó chính là tác phẩm kinh điển đặt nền móng cho cả một trường phái kiến trúc: Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) của Eric Evans.

Trong kiệt tác của mình, Evans đã giới thiệu một hệ thống các mẫu hình tinh hoa nhằm gắn kết chặt chẽ mã nguồn với mô hình cốt lõi của miền nghiệp vụ: Aggregate (Tập hợp kết hợp), Value Object (Đối tượng giá trị), Domain Event (Sự kiện miền), Domain Service (Dịch vụ miền), Repository (Kho lưu trữ) và nhiều khái niệm khác. Những mẫu hình này tiếp nối một cách hoàn hảo nơi Martin Fowler dừng lại, tạo thành một bộ công cụ vô cùng sắc bén để hiện thực hóa mẫu hình Domain Model.

Làm rõ thuật ngữ: Domain Model vs Tactical DDD

Hệ thống các mẫu hình mà Evans đưa ra thường được cộng đồng gọi chung là Tactical Domain-Driven Design (DDD Chiến thuật). Để tránh nhầm lẫn tai hại rằng hễ làm DDD là bắt buộc phải dùng các mẫu này, tác giả Vlad Khononov giữ nguyên thuật ngữ chuẩn mực ban đầu của Fowler: Mẫu hình ở đây là "Domain Model", còn Aggregates, Value Objects và Domain Services là các viên gạch cấu thành (Building Blocks) của nó.

2. Mẫu Mô Hình Miền (Domain Model Pattern)

Mẫu Domain Model được sinh ra để đối phó với những miền nghiệp vụ có logic cực kỳ phức tạp. Ở đây, thay vì chỉ có các thao tác CRUD thêm-sửa-xóa đơn giản, chúng ta phải đối mặt với:

  • Các chuỗi chuyển đổi trạng thái chằng chịt, phụ thuộc lẫn nhau (complicated state transitions).
  • Các quy tắc nghiệp vụ khắt khe (business rules).
  • Các Luật Bất Biến (Invariants): Những quy tắc và ràng buộc nghiệp vụ bắt buộc phải luôn luôn được bảo vệ toàn vẹn ở mọi thời điểm, trong mọi hoàn cảnh!

2.1. Tình huống thực tế: Vòng đời vé hỗ trợ Help Desk

Hãy tưởng tượng chúng ta đang xây dựng một hệ thống bàn hỗ trợ khách hàng (Help Desk). Dưới đây là một đoạn trích từ tài liệu yêu cầu nghiệp vụ mô tả vòng đời của một phiếu hỗ trợ (Support Ticket):

Những yêu cầu trên tạo nên một mạng nhện phụ thuộc chằng chịt giữa các quy tắc nghiệp vụ. Đây hoàn toàn không phải là một màn hình nhập liệu CRUD đơn giản!

Nếu cố tình dùng mẫu Active Record cho bài toán này, các quy tắc kiểm tra sẽ bị phân tán, sao chép vụn vặt ở khắp các tầng ứng dụng. Chỉ cần một lập trình viên sơ suất quên kiểm tra một điều kiện (ví dụ: cho phép agent bấm đóng một ticket đang bị escalate), trạng thái toàn vẹn của cả hệ thống sẽ lập tức bị phá hủy!

2.2. Phương thức cài đặt: Tách rời công nghệ hạ tầng

Mô hình miền (Domain Model) là một mô hình đối tượng kết hợp hữu cơ cả Hành vi (Behavior) lẫn Dữ liệu (Data). Các mẫu chiến thuật của DDD — Aggregates, Value Objects, Domain Events và Domain Services — chính là những viên gạch cấu thành nên mô hình này.

Tất cả các mẫu này đều chia sẻ một tôn chỉ bất di bất dịch: Đặt logic nghiệp vụ lên vị trí ưu tiên số 1!

Kiểm soát độ phức tạp (Complexity)

Bản thân logic nghiệp vụ của miền đã vốn dĩ vô cùng phức tạp. Do đó, các đối tượng dùng để mô hình hóa nó tuyệt đối không được đưa thêm bất kỳ độ phức tạp ngẫu nhiên nào từ công nghệ!

Mô hình miền phải hoàn toàn sạch bóng các mối bận tâm về hạ tầng (như kết nối SQL, ORM attributes, HTTP context). Các đối tượng mô hình phải là các đối tượng thuần túy: POCO trong .NET, POJO trong Java, POPO trong Python.

Ngôn ngữ Chung Phổ quát (Ubiquitous Language)

Việc loại bỏ các tạp chất kỹ thuật giúp các đối tượng trong Domain Model dễ dàng tuân thủ chuẩn xác hệ thống thuật ngữ của Ngôn ngữ Chung Phổ quát.

Mã nguồn giờ đây thực sự "nói" bằng ngôn ngữ nghiệp vụ, phản ánh đúng từng khái niệm và phương thức tư duy của các chuyên gia miền.

3. Đối Tượng Giá Trị (Value Objects)

3.1. Định danh bằng giá trị & Sai lầm trường ColorId

Value Object là một đối tượng được định danh thuần túy bởi sự kết hợp của các giá trị bên trong nó.

Hãy xem xét một ví dụ kinh điển: Đối tượng Màu sắc (Color):

class Color
{
    int _red;
    int _green;
    int _blue;
}

Sự kết hợp cụ thể của 3 giá trị red, green, và blue tạo nên một màu sắc duy nhất. Nếu bạn thay đổi giá trị của một trường bất kỳ, bạn sẽ nhận được một màu sắc hoàn toàn mới. Không thể có hai màu sắc khác nhau lại có cùng giá trị RGB; và ngược lại, hai thực thể màu có cùng giá trị RGB chắc chắn là cùng một màu!

Do đó, chúng ta hoàn toàn không cần bất kỳ một trường định danh (ID) tường minh nào để phân biệt các màu sắc!

Hình 6-1: Trường ColorId dư thừa tạo kẽ hở cho lỗi bất nhất dữ liệu
Hình 6-1. Trường ColorId dư thừa. Việc gán ID cho một Value Object không những vô nghĩa mà còn mở toang cánh cửa dẫn đến lỗi: Bạn có thể vô tình tạo ra hai dòng dữ liệu có cùng giá trị RGB (255, 0, 0) nhưng mang hai ID khác nhau, khiến phép so sánh định danh bị sai lệch hoàn toàn!

3.2. Mùi mã nguồn "Ám ảnh kiểu nguyên thủy" (Primitive Obsession)

Thói quen lạm dụng độc quyền các kiểu dữ liệu nguyên thủy có sẵn của ngôn ngữ lập trình (như string, int, decimal, dictionary) để biểu diễn các khái niệm nghiệp vụ được gọi là mùi mã nguồn Ám ảnh kiểu nguyên thủy (Primitive Obsession code smell).

Hãy xem xét cách thiết kế ngây thơ phổ biến sau:

class Person
{
    private int _id;
    private string _firstName;
    private string _lastName;
    private string _landlinePhone;
    private string _mobilePhone;
    private string _email;
    private int _heightMetric;
    private string _countryCode;
    ...
}

var dave = new Person(
    id: 30217,
    firstName: "Dave",
    lastName: "Ancelovici",
    landlinePhone: "023745001",
    mobilePhone: "0873712503",
    email: "dave@learning-ddd.com",
    heightMetric: 180,
    countryCode: "BG"
);

Trong cách viết trên, hầu hết các thuộc tính đều là string và giá trị được gán dựa trên... quy ước ngầm! Ví dụ: người ta ngầm quy ước countryCode phải là chuỗi 2 ký tự viết hoa. Hệ thống không thể tin tưởng người dùng luôn truyền đúng, vì vậy lớp Person buộc phải nhồi nhét hàng tá logic kiểm tra hợp lệ (validation) cho từng trường.

Hậu quả: Logic validation bị phân mảnh, trùng lặp khắp nơi và rất dễ bị các lập trình viên khác vô tình bỏ quên khi phát triển tính năng mới.

Giải pháp thanh lịch với Value Objects:

class Person
{
    private PersonId _id;
    private Name _name;
    private PhoneNumber _landline;
    private PhoneNumber _mobile;
    private EmailAddress _email;
    private Height _height;
    private CountryCode _country;
    ...
}

var dave = new Person(
    id: new PersonId(30217),
    name: new Name("Dave", "Ancelovici"),
    landline: PhoneNumber.Parse("023745001"),
    mobile: PhoneNumber.Parse("0873712503"),
    email: EmailAddress.Parse("dave@learning-ddd.com"),
    height: Height.FromMetric(180),
    country: CountryCode.Parse("BG")
);

3.3. Sức mạnh biểu đạt và Tính Bất Biến (Immutability)

Hãy nhìn vào sự lột xác của đoạn mã trên:

  • Tính tường minh tuyệt đối: Biến country không cần phải đặt tên dài dòng là countryCode nữa, vì chính kiểu dữ liệu CountryCode đã tự nói lên bản chất của nó.
  • Tự kiểm thực (Self-Validating): Không cần validate thủ công trước khi gán. Bản thân Value Object EmailAddress hay PhoneNumber không bao giờ cho phép một giá trị rác được khởi tạo!
  • Đóng gói hành vi giàu có (Rich Behavior):
    // Chiều cao: tự động chuyển đổi hệ mét và hệ Anh
    var heightMetric = Height.Metric(180);
    var heightImperial = Height.Imperial(5, 3);
    bool firstIsHigher = heightMetric > heightImperial; // true
    
    // Số điện thoại: tự bóc tách mã quốc gia, phân loại máy bàn/di động
    var phone = PhoneNumber.Parse("+359877123503");
    string country = phone.Country; // "BG"
    string type = phone.PhoneType;   // "MOBILE"
    
    // Màu sắc: hành vi phối màu nghiệp vụ sinh ra màu mới
    var red = Color.FromRGB(255, 0, 0);
    var yellow = red.MixWith(Color.Green);
Quy tắc bất biến: Value Objects là Bất Biến (Immutable)!

Bởi vì việc thay đổi bất kỳ trường nào của Value Object về mặt khái niệm sẽ tạo ra một giá trị hoàn toàn mới, nên Value Objects bắt buộc phải được cài đặt dưới dạng BẤT BIẾN (Immutable).

Khi một hành vi được kích hoạt (như hàm MixWith ở trên), nó không bao giờ được thay đổi trạng thái của đối tượng hiện tại, mà phải khởi tạo và trả về một phiên bản Value Object hoàn toàn mới!

Nhờ tính bất biến, Value Objects hoàn toàn sạch bóng tác dụng phụ (side-effect free) và an toàn đa luồng tuyệt đối (thread-safe).

3.4. Khi nào nên dùng Value Objects?

Câu trả lời ngắn gọn nhất: BẤT CỨ KHI NÀO BẠN CÓ THỂ!

Quy tắc ngón tay cái: Hãy dùng Value Objects cho tất cả các phần tử trong miền nghiệp vụ dùng để mô tả đặc tính hoặc thuộc tính của các đối tượng khác (đặc biệt là thuộc tính của Entities).

Đặc biệt quan trọng: Tiền tệ (Money Pattern)

Việc dùng kiểu số nguyên thủy (như decimal hay double) để biểu diễn Tiền là nguồn gốc của vô số lỗi làm tròn và thảm họa tài chính. Tiền bắt buộc phải là một Value Object bao gồm cả Số tiền (Amount) và Đơn vị tiền tệ (Currency) kèm theo các quy tắc làm tròn nghiệp vụ nghiêm ngặt!

4. Thực Thể (Entities)

4.1. Định danh tường minh và Tính khả biến

Thực thể (Entity) là khái niệm đối lập hoàn toàn với Value Object. Nó bắt buộc phải có một trường định danh tường minh (explicit ID) để phân biệt giữa các cá thể khác nhau.

Ví dụ: Một Con Người (Person). Hai người hoàn toàn có thể trùng tên họ, cùng ngày tháng năm sinh, cùng chiều cao cân nặng. Nhưng họ hiển nhiên là hai con người độc lập khác nhau! Do đó, chúng ta cần một trường định danh độc nhất (như Số Căn Cước Công Dân hoặc mã định danh PersonId) để phân biệt (xem Hình 6-2).

Hình 6-2: Thêm trường định danh tường minh Id để phân biệt các thực thể trùng thuộc tính
Hình 6-2. Đưa vào trường định danh độc nhất Id: Cho phép phân biệt chính xác giữa các thực thể ngay cả khi tất cả các thuộc tính còn lại của chúng hoàn toàn trùng khớp nhau.

Hai điểm khác biệt cốt tử giữa Entity và Value Object:

  1. Tính khả biến (Mutable): Trạng thái của một Entity được dự liệu là sẽ thay đổi liên tục theo thời gian trong suốt vòng đời của nó (người có thể đổi số điện thoại, đổi địa chỉ nhà, thăng chức). Trong khi đó, trường Id của Entity là bất biến tuyệt đối.
  2. Thành phần cấu tạo: Các thuộc tính mô tả trạng thái của Entity thường được cấu thành từ chính các Value Objects!

4.2. Vì sao Entity không bao giờ đứng độc lập?

Bạn có thể nhận thấy một điều kỳ lạ: Ở phần đầu chương, tác giả không hề liệt kê "Entity" như một viên gạch độc lập của Domain Model. Đó hoàn toàn không phải là sự sơ suất!

Lý do là vì: Trong Domain-Driven Design, chúng ta KHÔNG BAO GIỜ cài đặt Entity một cách biệt lập, mà chỉ cài đặt Entity bên trong ngữ cảnh của mẫu hình AGGREGATE!

5. Tập Hợp Kết Hợp (Aggregates)

Một Aggregate bản thân nó cũng là một Entity: nó có trường định danh độc nhất và trạng thái của nó biến đổi theo thời gian. Thế nhưng, nó vượt trội hơn một Entity thông thường rất nhiều: Mục tiêu tối thượng của mẫu Aggregate là bảo vệ tính nhất quán dữ liệu tuyệt đối (Data Consistency)!

5.1. Ranh giới thực thi tính nhất quán (Consistency Enforcement Boundary)

Vì trạng thái của Entity có thể bị thay đổi, điều này mở ra nguy cơ dữ liệu bị làm sai hỏng từ bên ngoài. Để ngăn chặn thảm họa đó, mẫu Aggregate vạch ra một ranh giới thép ngăn cách giữa nội bộ của nó và thế giới bên ngoài: Aggregate chính là ranh giới thực thi tính nhất quán.

5.2. Ranh giới giao dịch: Một Aggregate trên một Transaction

Vì trạng thái của Aggregate chỉ có thể được biến đổi bởi chính nó, nên Aggregate cũng đồng thời đóng vai trò là Ranh Giới Giao Dịch (Transactional Boundary).

Mọi thay đổi đối với trạng thái của Aggregate bắt buộc phải được commit vào cơ sở dữ liệu dưới dạng một thao tác nguyên tử duy nhất (one atomic transaction): Hoặc tất cả thay đổi cùng được lưu, hoặc không có gì được lưu!

Luật bất di bất dịch của DDD: Mỗi Database Transaction chỉ sửa ĐÚNG 1 Aggregate!

Tuyệt đối không một thao tác nào của hệ thống được phép giả định một transaction bao trùm nhiều Aggregates. Mỗi database transaction chỉ được phép sửa đổi và commit duy nhất MỘT thể hiện Aggregate!

Nếu bạn cảm thấy cần phải cập nhật đồng thời nhiều Aggregates trong cùng một transaction, điều đó chứng tỏ bạn đã thiết kế sai ranh giới giao dịch, và do đó thiết kế sai ranh giới của Aggregate!

5.3. Cây phả hệ thực thể (Hierarchy of Entities)

Vậy nếu chúng ta thực sự cần biến đổi nhiều đối tượng cùng một lúc thì sao? Đây chính là lý do mẫu hình này có tên là Aggregate (Tập hợp kết hợp)!

Để hỗ trợ các thay đổi trên nhiều đối tượng phải diễn ra trong một transaction duy nhất, Aggregate được cấu trúc như một cây phả hệ gồm nhiều Entities và Value Objects cùng chia sẻ chung một ranh giới nhất quán giao dịch (xem Hình 6-3).

Hình 6-3: Aggregate như một cây phả hệ các thực thể và đối tượng giá trị
Hình 6-3. Aggregate là một cây phả hệ các thực thể và đối tượng giá trị cùng chia sẻ chung một ranh giới giao dịch nguyên tử.

Ví dụ: Trong Aggregate Ticket, chúng ta có danh sách các thực thể con Message. Khi kiểm tra quy tắc tự động thu hồi ticket nếu agent chưa đọc tin nhắn quá 50% thời hạn, toàn bộ dữ liệu của Ticket và các Message con đều được đọc từ nguồn nhất quán mạnh mẽ và được bảo đảm không bị sửa đổi chéo:

public class Ticket
{
    ...
    List _messages;

    public void Execute(EvaluateAutomaticActions cmd)
    {
        // Kiểm tra đồng thời trạng thái của Ticket và các Message con
        if (this.IsEscalated && this.RemainingTimePercentage < 0.5
            && GetUnreadMessagesCount(for: AssignedAgent) > 0)
        {
            _agent = AssignNewAgent();
        }
    }
}

5.4. Tham chiếu Aggregate khác bằng ID

Vì tất cả các đối tượng bên trong Aggregate chia sẻ chung một ranh giới giao dịch, nên nếu một Aggregate phình to quá mức, hiệu năng và khả năng mở rộng của hệ thống sẽ sụp đổ nghiêm trọng!

Nguyên tắc vàng: Hãy giữ cho các Aggregate càng nhỏ càng tốt! Chỉ những dữ liệu nào mà logic nghiệp vụ của Aggregate bắt buộc phải ở trạng thái nhất quán mạnh (Strongly Consistent) thì mới được đưa vào bên trong Aggregate. Tất cả những dữ liệu có thể chấp nhận nhất quán sau cùng (Eventually Consistent) phải được đặt ra bên ngoài — thuộc về các Aggregate khác!

Hình 6-4: Aggregate đóng vai trò là ranh giới nhất quán, tham chiếu các aggregate khác qua Id
Hình 6-4. Aggregate là ranh giới nhất quán. Các đối tượng bên ngoài được tham chiếu thuần túy thông qua Id của chúng (ví dụ: UserId, ProductId) thay vì nhúng trực tiếp cả đối tượng vào.
public class Ticket
{
    // Các đối tượng bên ngoài: chỉ tham chiếu bằng ID
    private UserId _customerId;
    private List _productIds;
    private UserId _assignedAgentId;

    // Các đối tượng bên trong ranh giới nhất quán mạnh:
    private List _messages;
    ...
}

5.5. Gốc Aggregate (Aggregate Root)

Trong một cây phả hệ gồm nhiều thực thể con, thực thể đóng vai trò là cửa ngõ giao tiếp công khai duy nhất của toàn bộ Aggregate được gọi là Gốc Aggregate (Aggregate Root) (xem Hình 6-5).

Hình 6-5: Gốc Aggregate (Aggregate Root)
Hình 6-5. Gốc Aggregate (Aggregate Root): Thế giới bên ngoài không bao giờ được phép trực tiếp gọi hay sửa đổi các thực thể con. Mọi tương tác bắt buộc phải đi qua Aggregate Root!

Nếu bạn muốn đánh dấu một tin nhắn là đã đọc, bạn không thể gọi trực tiếp message.WasRead = true, mà phải gửi lệnh qua Aggregate Root: ticket.Execute(new AcknowledgeMessage(messageId)).

5.6. Sự Kiện Miền (Domain Events)

Domain Event là một thông điệp mô tả một sự kiện nghiệp vụ quan trọng đã diễn ra trong quá khứ của miền nghiệp vụ.

Vì mô tả sự việc đã hoàn tất, tên của Domain Event bắt buộc phải được đặt ở thì quá khứ: TicketAssigned, TicketEscalated, MessageReceived.

Hình 6-6: Luồng xuất bản Sự kiện Miền (Domain events publishing flow)
Hình 6-6. Luồng xuất bản Sự kiện Miền: Aggregate phát sinh sự kiện nội bộ; sau khi giao dịch commit thành công, các sự kiện này được xuất bản ra ngoài để các thành phần quan tâm lắng nghe và phản ứng.

6. Dịch Vụ Miền (Domain Services)

Trong thực tế, bạn sẽ gặp những logic nghiệp vụ quan trọng mà nó không thuộc về bất kỳ Aggregate hay Value Object cụ thể nào, hoặc nó cần đọc dữ liệu từ nhiều Aggregates khác nhau để tính toán. DDD đề xuất hiện thực hóa logic đó dưới dạng một Dịch Vụ Miền (Domain Service).

Đặc tính của Domain Service:

  • Là một đối tượng phi trạng thái (Stateless).
  • Nhiệm vụ chính là phối hợp đọc dữ liệu từ nhiều nguồn để thực hiện phép tính toán hoặc phân tích nghiệp vụ phức tạp.
  • Lưu ý cốt tử: Domain Service hoàn toàn không phải là lỗ hổng để lách luật nguyên tắc một aggregate trên một transaction! Domain Service chỉ dùng để tính toán hoặc điều phối; việc sửa đổi trạng thái vẫn phải tuân thủ nghiêm ngặt ranh giới của từng Aggregate riêng lẻ.
  • Lưu ý thuật ngữ: Domain Service trong DDD hoàn toàn không liên quan đến Microservices hay Service-Oriented Architecture (SOA). Nó chỉ đơn giản là một lớp chứa logic thuần túy.

7. Quản Lý Độ Phức Tạp: Lý Thuyết Bậc Tự Do

Tại sao việc sử dụng Aggregates và Value Objects lại giúp chúng ta kiểm soát được độ phức tạp của phần mềm?

Trong cuốn sách kinh điển The Choice, bậc thầy quản trị kinh doanh Eliyahu M. Goldratt đã đưa ra một định nghĩa xuất sắc: Độ phức tạp của một hệ thống được đo bằng độ khó khăn trong việc kiểm soát và dự đoán hành vi của hệ thống đó. Điều này được phản ánh thông qua BẬC TỰ DO (Degrees of Freedom) của hệ thống.

Bậc tự do là số lượng điểm dữ liệu độc lập tối thiểu cần có để mô tả trọn vẹn trạng thái của hệ thống. Hãy so sánh hai lớp đối tượng dưới đây:

Lớp A (ClassA)

public class ClassA
{
    public int A { get; set; }
    public int B { get; set; }
    public int C { get; set; }
    public int D { get; set; }
    public int E { get; set; }
}

Để biết trạng thái của ClassA, bạn bắt buộc phải biết giá trị của cả 5 biến độc lập. Do đó, ClassA có 5 bậc tự do.

Lớp B (ClassB)

public class ClassB
{
    private int _a, _d;
    public int A {
        get => _a;
        set { _a = value; B = value / 2; C = value / 3; }
    }
    public int B { get; private set; }
    public int C { get; private set; }
    public int D {
        get => _d;
        set { _d = value; E = value * 2; }
    }
    public int E { get; private set; }
}

Ở ClassB, giá trị của B, C và E hoàn toàn phụ thuộc vào A và D. Chỉ cần biết A và D là suy ra được toàn bộ! ClassB chỉ có 2 bậc tự do.

Đóng gói Invariants = Giảm Bậc Tự Do = Giảm Độ Phức Tạp!

Lớp nào khó dự đoán và dễ gây lỗi hơn? Hiển nhiên là lớp có nhiều bậc tự do hơn (ClassA).
Đó chính là điều kỳ diệu mà Value Objects và Aggregates mang lại: Bằng cách đóng gói chặt chẽ các luật bất biến bên trong ranh giới của mình, chúng triệt tiêu các bậc tự do dư thừa, biến hệ thống từ một mớ hỗn độn khó lường thành một cỗ máy chuẩn xác, an toàn và cực kỳ dễ kiểm soát!

8. Tổng Kết Chương 6

Chương 6 đã đưa chúng ta chạm vào trái tim tinh hoa nhất của Thiết Kế Hướng Miền:

  • Domain Model: Mô hình đối tượng kết hợp cả dữ liệu và hành vi, đặt logic nghiệp vụ lên hàng đầu và sạch bóng mã nguồn công nghệ hạ tầng.
  • Value Objects: Định danh bằng giá trị, bất biến (immutable), tự kiểm thực, đóng gói logic thao tác dữ liệu, an toàn đa luồng và loại bỏ Primitive Obsession.
  • Entities: Cần trường định danh duy nhất (Id), có trạng thái khả biến và vòng đời kéo dài; luôn nằm trong Aggregate.
  • Aggregates: Ranh giới thực thi tính nhất quán và ranh giới giao dịch; mỗi transaction chỉ sửa 1 aggregate; các aggregate khác chỉ tham chiếu qua ID; giao tiếp thông qua Aggregate Root và Domain Events.
  • Domain Services: Đối tượng phi trạng thái chứa các thuật toán tính toán nghiệp vụ bắc cầu qua nhiều Aggregates.

9. Bài Tập & Trắc Nghiệm Đánh Giá Tri Thức (Exercises)

Hãy thử sức với 5 câu hỏi trắc nghiệm dưới đây để củng cố các nguyên lý cốt tử của Domain Model. Đáp án và giải thích được trích xuất trực tiếp từ Appendix B của tác giả Vlad Khononov.

Câu 1: Phát biểu nào sau đây là ĐÚNG NHẤT về Đối Tượng Giá Trị (Value Objects)?
A. Value objects chỉ có thể chứa dữ liệu mà không được chứa hành vi.
B. Value objects chỉ có thể chứa hành vi mà không được chứa dữ liệu.
C. Value objects là bất biến (Value objects are immutable).
D. Trạng thái của Value objects có thể thay đổi sau khi khởi tạo.
Đáp án chính xác: C (Value objects are immutable).
Giải thích chi tiết (Appendix B): Value objects bắt buộc phải là bất biến. Mọi thao tác làm thay đổi giá trị sẽ tạo ra một thể hiện mới. Ngoài ra, Value objects hoàn toàn có thể (và rất nên) chứa cả dữ liệu lẫn các phương thức hành vi nghiệp vụ phong phú.
Câu 2: Nguyên tắc chỉ đạo tổng quát khi thiết kế ranh giới của một Aggregate là gì?
A. Một aggregate chỉ được phép chứa duy nhất một entity.
B. Aggregate nên được thiết kế càng nhỏ càng tốt, miễn là các yêu cầu về tính nhất quán dữ liệu của miền nghiệp vụ được bảo toàn.
C. Aggregate nên được thiết kế càng rộng lớn bao quát càng tốt để tối đa hóa tính nhất quán.
D. Kích thước tùy tiện thế nào cũng được.
Đáp án chính xác: B.
Giải thích chi tiết (Appendix B): Quy tắc ngón tay cái là giữ cho các Aggregate càng nhỏ gọn càng tốt (nhằm tối ưu hiệu năng, giảm thiểu xung đột tương tranh và tăng khả năng mở rộng), đồng thời chỉ gom những dữ liệu bắt buộc phải đảm bảo tính nhất quán mạnh (strong consistency) vào chung ranh giới.
Câu 3: Vì sao trong mỗi database transaction chỉ được phép sửa đổi và commit DUY NHẤT MỘT thể hiện Aggregate?
A. Để bảo đảm mô hình có thể đạt hiệu năng cao dưới tải lớn.
B. Để bảo đảm các ranh giới giao dịch được chuẩn xác (To ensure correct transactional boundaries).
C. Không có quy tắc này, nó phụ thuộc vào ý thích của lập trình viên.
D. Để cho phép làm việc với cơ sở dữ liệu NoSQL không hỗ trợ multi-record transaction.
Đáp án chính xác: B (To ensure correct transactional boundaries).
Giải thích chi tiết (Appendix B): Aggregate chính là ranh giới của tính nhất quán và ranh giới giao dịch. Nhu cầu phải commit nhiều aggregates trong cùng một transaction là dấu hiệu báo động rằng ranh giới aggregate đã bị phân chia sai lệch về mặt nghiệp vụ.
Câu 4: Phát biểu nào sau đây mô tả đúng nhất về mối quan hệ giữa các viên gạch cấu thành của Domain Model?
A. Value objects mô tả các thuộc tính của entities.
B. Value objects có thể tự mình phát ra domain events.
C. Một aggregate chứa một hoặc nhiều entities bên trong nó.
D. Cả A và C đều đúng.
Đáp án chính xác: D (Cả A và C đều đúng).
Giải thích chi tiết (Appendix B): Value objects dùng để mô tả các thuộc tính của entities, và một aggregate cấu thành từ một cây phả hệ gồm một hoặc nhiều entities (cùng các value objects) với một entity đóng vai trò là Aggregate Root.
Câu 5: Phát biểu nào sau đây là CHÍNH XÁC về sự khác biệt giữa Active Record và Aggregate?
A. Active records chỉ chứa dữ liệu, trong khi aggregates cũng chứa hành vi.
B. Một aggregate đóng gói toàn bộ logic nghiệp vụ bên trong nó, trong khi logic nghiệp vụ thao tác trên active record có thể nằm rải rác bên ngoài ranh giới của nó.
C. Aggregates chỉ chứa dữ liệu, trong khi active records chứa cả dữ liệu và hành vi.
D. Một aggregate chứa một tập hợp các active records bên trong.
Đáp án chính xác: B.
Giải thích chi tiết (Appendix B): Điểm khác biệt mấu chốt là tính đóng gói: Aggregate là ranh giới thực thi tính nhất quán nghiêm ngặt, cấm tuyệt đối việc sửa đổi trạng thái từ bên ngoài và tự mình thực thi toàn bộ logic bảo vệ invariants. Ngược lại, Active Record thường để lộ các getters/setters công khai và logic nghiệp vụ thường nằm ở các transaction script bên ngoài thao tác trên nó.
Chương trước ← Chương 5: Triển khai Logic Nghiệp vụ Đơn giản
Chương tiếp theo Chương 7: Mô Hình Hóa Chiều Thời Gian (Event Sourcing) →