Triển Khai Logic Nghiệp Vụ Đơn Giản
Implementing Simple Business Logic • Hai mẫu hình kinh điển: Kịch bản Giao dịch (Transaction Script) và Bản ghi Hoạt động (Active Record)
Trong Phần I, chúng ta đã mổ xẻ câu hỏi "Cái gì" (What) và "Tại sao" (Why) của phần mềm: Bạn đã học cách phân tích các miền nghiệp vụ, nhận diện các miền con (subdomains) và giá trị chiến lược của chúng, rồi chuyển hóa tri thức đó thành thiết kế của các Bounded Contexts.
Bước sang Phần II của cuốn sách, chúng ta sẽ chuyển bánh lái từ Chiến Lược (Strategy) sang Chiến Thuật (Tactics) — trả lời cho câu hỏi "Làm như thế nào" (How) trong thiết kế phần mềm:
- Chương 5 đến 7: Các mẫu triển khai logic nghiệp vụ giúp mã nguồn "nói" chuẩn xác Ngôn ngữ Chung Phổ quát. Chương 5 bàn về logic đơn giản (Transaction Script, Active Record); Chương 6 đưa ra mẫu Domain Model cho logic phức tạp; Chương 7 mở rộng mô hình miền với chiều thời gian (Event Sourcing).
- Chương 8: Tổ chức kiến trúc bên trong một Bounded Context: Kiến trúc phân tầng (Layered), Cổng & Bộ điều hợp (Ports & Adapters / Hexagonal) và CQRS.
- Chương 9: Các mẫu điều phối tương tác kỹ thuật: Hiện thực hóa tích hợp Bounded Contexts, Outbox Pattern và quy trình phức tạp giữa các thành phần (Saga, Process Manager).
1. Tầm Quan Trọng Của Logic Nghiệp Vụ
Logic nghiệp vụ (Business logic) là phần quan trọng nhất của mọi phần mềm. Đó là lý do duy nhất khiến phần mềm được quyết định đầu tư và xây dựng ngay từ ban đầu.
Giao diện người dùng (UI) của hệ thống có thể vô cùng quyến rũ, bắt mắt; cơ sở dữ liệu bên dưới có thể nhanh như chớp và mở rộng quy mô không giới hạn. Nhưng nếu phần mềm đó không đem lại giá trị hữu ích cho công việc kinh doanh của doanh nghiệp, thì tất cả những thứ hào nhoáng đó chẳng qua chỉ là một bản trình diễn công nghệ đắt đỏ (expensive technology demo) vô tích sự!
Như chúng ta đã thấy ở Chương 1 và 2, không phải tất cả các miền con nghiệp vụ đều được sinh ra bình đẳng. Mỗi subdomain sở hữu mức độ phức tạp và tầm quan trọng chiến lược rất khác nhau. Chương 5 bắt đầu hành trình khám phá các kỹ thuật cài đặt mã nguồn cho logic nghiệp vụ với hai mẫu hình phù hợp cho những bài toán có độ phức tạp logic tương đối đơn giản: Transaction Script và Active Record.
2. Kịch Bản Giao Dịch (Transaction Script)
"Transaction Script tổ chức logic nghiệp vụ theo từng thủ tục, trong đó mỗi thủ tục xử lý trọn vẹn một yêu cầu đơn lẻ xuất phát từ tầng trình bày."— Martin Fowler, tác giả cuốn sách kinh điển "Patterns of Enterprise Application Architecture"
2.1. Định nghĩa và Giao diện hệ thống
Giao diện công khai (public interface) của một hệ thống có thể được nhìn nhận như một tập hợp các giao dịch nghiệp vụ (business transactions) mà các bên tiêu thụ bên ngoài có thể kích hoạt thực thi, như minh họa trong Hình 5-1. Những giao dịch này có thể đọc thông tin do hệ thống quản lý, chỉnh sửa dữ liệu, hoặc kết hợp cả hai.
Mẫu hình Transaction Script tổ chức logic nghiệp vụ của hệ thống dựa trên các thủ tục (procedures). Mỗi thủ tục cài đặt trọn gói một thao tác do người dùng hoặc hệ thống bên ngoài gọi thông qua public interface. Nói cách khác, các thao tác công khai của hệ thống được sử dụng làm ranh giới đóng gói (encapsulation boundaries).
2.2. Phương thức cài đặt và Yêu cầu Giao dịch (Transactional Behavior)
Mỗi thủ tục trong Transaction Script được cài đặt như một đoạn mã kịch bản tuần tự, trực diện và đơn giản. Nó có thể sử dụng một tầng trừu tượng mỏng để tương tác với cơ sở dữ liệu, hoặc thậm chí được quyền truy cập và thực thi trực tiếp các câu lệnh SQL vào database.
Dưới đây là một ví dụ trực quan về một Transaction Script chuyển đổi hàng loạt tệp JSON sang XML trong C#:
DB.StartTransaction();
var job = DB.LoadNextJob();
var json = LoadFile(job.Source);
var xml = ConvertJsonToXml(json);
WriteFile(job.Destination, xml.ToString());
DB.MarkJobAsCompleted(job);
DB.Commit();
3. "Không Hề Đơn Giản Như Bạn Tưởng!" (It's Not That Easy!)
Khi tác giả Vlad Khononov giới thiệu mẫu Transaction Script trong các khóa đào tạo DDD, học viên thường nhướng mày hoài nghi: "Mẫu này đơn giản thế, có đáng mất thời gian học không thầy? Chẳng phải chúng ta đến đây để học những kỹ thuật nâng cao hơn sao?"
Sự thật là: Transaction Script chính là nền móng nền tảng của mọi mẫu triển khai logic nghiệp vụ nâng cao mà bạn sẽ học ở các chương sau! Hơn thế nữa, mặc dù bề ngoài trông rất giản dị, nhưng đây lại là mẫu hình dễ bị lập trình sai nhất.
Một số lượng khổng lồ các sự cố nghiêm trọng trên môi trường production mà tác giả từng tham gia gỡ rối và khắc phục rốt cuộc đều bắt nguồn từ việc cài đặt sai lệch hành vi giao dịch trong logic nghiệp vụ. Dưới đây là 3 cạm bẫy thực tế kinh điển:
3.1. Cạm bẫy 1: Thiếu vắng hành vi giao dịch (Lack of Transactional Behavior)
Sai lầm phổ biến nhất là thực hiện nhiều câu lệnh cập nhật dữ liệu nhưng lại quên không bọc chúng trong một transaction duy nhất. Hãy xem xét phương thức dưới đây:
public class LogVisit
{
...
public void Execute(Guid userId, DateTime visitedOn)
{
// Dòng 07: Cập nhật thời gian ghé thăm gần nhất của User
_db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2", visitedOn, userId);
// Dòng 09: Ghi bản ghi lịch sử vào bảng VisitsLog
_db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date) VALUES(@p1, @p2)", userId, visitedOn);
}
}
Nếu có bất kỳ sự cố nào xảy ra sau khi dòng 07 thực thi xong nhưng trước khi dòng 09 hoàn tất (như mất kết nối mạng, cơ sở dữ liệu bị timeout, xảy ra deadlock, hoặc máy chủ đột ngột sập nguồn), hệ thống sẽ rơi vào trạng thái bất nhất vĩnh viễn: Bảng Users đã được cập nhật nhưng bảng VisitsLog lại hoàn toàn không có bản ghi tương ứng!
Đối với cơ sở dữ liệu quan hệ, việc khắc phục rất dễ dàng nhờ cơ chế transaction có sẵn:
public class LogVisit
{
...
public void Execute(Guid userId, DateTime visitedOn)
{
try
{
_db.StartTransaction();
_db.Execute(@"UPDATE Users SET last_visit=@p1 WHERE user_id=@p2", visitedOn, userId);
_db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date) VALUES(@p1, @p2)", userId, visitedOn);
_db.Commit();
}
catch
{
_db.Rollback();
throw;
}
}
}
3.2. Cạm bẫy 2: Giao dịch phân tán (Distributed Transactions)
Trong các hệ thống phân tán hiện đại, một kịch bản rất phổ biến là: ghi dữ liệu vào database, sau đó phát thông báo cho các dịch vụ khác thông qua việc bắn tin nhắn lên một Message Bus:
public class LogVisit
{
...
public void Execute(Guid userId, DateTime visitedOn)
{
// Cập nhật Database
_db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2", visitedOn, userId);
// Bắn Message lên Message Bus
_messageBus.Publish("VISITS_TOPIC", new { UserId = userId, VisitDate = visitedOn });
}
}
Tương tự ví dụ trước, bất kỳ sự cố nào xảy ra sau khi cập nhật database nhưng trước khi gửi message thành công sẽ làm hỏng trạng thái hệ thống: Dữ liệu đã thay đổi nhưng các thành phần khác lại không hề hay biết!
Tuy nhiên, khắc phục lỗi này không hề đơn giản. Việc áp dụng giao dịch phân tán 2 pha (Two-Phase Commit - 2PC) bắc cầu qua nhiều kho lưu trữ vừa cồng kềnh, dễ lỗi, lại bóp nghẹt khả năng mở rộng quy mô.
Để giải quyết triệt để vấn đề này mà không cần đến distributed transactions, ở Chương 8 chúng ta sẽ tìm hiểu kiến trúc CQRS, và ở Chương 9 chúng ta sẽ học mẫu hình Outbox Pattern — cho phép xuất bản tin nhắn đáng tin cậy 100% đồng thời với việc commit dữ liệu vào database!
3.3. Cạm bẫy 3: Giao dịch phân tán ngầm định & Tính Idempotent
Hãy quan sát phương thức tưởng chừng vô hại và đơn giản đến mức không thể đơn giản hơn sau:
public class LogVisit
{
...
public void Execute(Guid userId)
{
_db.Execute("UPDATE Users SET visits=visits+1 WHERE user_id=@p1", userId);
}
}
Phương thức này chỉ cập nhật đúng một giá trị, trong một bảng duy nhất, trên một cơ sở dữ liệu duy nhất! Thế nhưng, nó vẫn tiềm ẩn một giao dịch phân tán ngầm định có thể phá hủy tính nhất quán của dữ liệu! Tại sao lại như vậy?
LogVisit cập nhật dữ liệu vào database và thông báo kết quả thực thi cho bên gọi (Caller). Quá trình truyền tin kết quả này chính là một nhánh của giao dịch phân tán!
Như minh họa trong Hình 5-2, phương thức này phải giao tiếp với hai bên: Cơ sở dữ liệu và Tiến trình bên ngoài gọi phương thức (Caller).
Mặc dù phương thức trả về kiểu void, nó vẫn ngầm thông báo trạng thái: nếu có lỗi nó sẽ ném ra ngoại lệ (exception). Điều gì sẽ xảy ra nếu:
- Database đã cập nhật thành công (visits tăng từ 10 lên 11).
- Nhưng kết nối mạng giữa Caller và API bị đứt đột ngột trước khi phản hồi HTTP 200 kịp gửi về; hoặc tiến trình Caller bị sập trước khi kịp ghi nhận kết quả thành công?
Bên gọi (Caller) đinh ninh rằng thao tác bị thất bại nên sẽ tự động thử lại (Retry)! Khi chạy lại, biến đếm visits sẽ lại bị tăng thêm một lần nữa. Rốt cuộc, người dùng mới chỉ ghé thăm 1 lần nhưng bộ đếm lại bị tăng thành 2 lần!
Hai cách khắc phục chuẩn mực trong DDD:
Cách 1: Biến thao tác thành Idempotent
Yêu cầu bên gọi truyền trực tiếp giá trị đích của bộ đếm (Caller đọc giá trị hiện tại, tự tăng lên 1 đơn vị rồi gửi giá trị đó đi). Dù có gọi lại 100 lần thì kết quả cuối cùng vẫn không thay đổi:
public void Execute(Guid userId, long visits)
{
_db.Execute(
"UPDATE Users SET visits=@p1 WHERE user_id=@p2",
visits, userId
);
}
Cách 2: Khóa Lạc Quan (Optimistic Concurrency Control)
Bên gọi truyền giá trị mong đợi (expected value). Thao tác cập nhật sẽ chỉ thực thi nếu giá trị hiện tại trong DB trùng khớp với giá trị ban đầu mà Caller đọc được:
public void Execute(Guid userId, long expectedVisits)
{
_db.Execute(@"UPDATE Users SET visits=visits+1
WHERE user_id=@p1 AND visits=@p2",
userId, expectedVisits);
}
3.4. Khi nào nên dùng Transaction Script?
Mẫu Transaction Script cực kỳ thích hợp cho các bài toán nghiệp vụ tuyến tính, thủ tục đơn giản. Ví dụ điển hình là các quy trình ETL (Extract - Transform - Load): trích xuất dữ liệu từ nguồn, biến đổi định dạng, và nạp vào đích (xem Hình 5-3).
Transaction Script là mảnh ghép tự nhiên cho:
- Supporting Subdomains: Nơi logic nghiệp vụ vốn dĩ đơn giản, không mang tính cạnh tranh.
- Bộ chuyển đổi (Adapters) tích hợp: Dùng để giao tiếp với các hệ thống Generic Subdomains bên ngoài hoặc một phần của Lớp Chống Tha Hóa (Anticorruption Layer).
Ưu điểm lớn nhất: Đơn giản tối đa, không cần nhiều tầng trừu tượng, tối ưu hiệu năng runtime và rất dễ đọc hiểu.
Nhược điểm chí mạng: Khi logic nghiệp vụ trở nên phức tạp, Transaction Script sẽ dẫn đến sự trùng lặp mã nguồn nghiêm trọng giữa các transaction. Khi code trùng lặp bị lệch pha (out of sync), hệ thống sẽ rơi vào trạng thái bất nhất. Vì vậy: TUYỆT ĐỐI KHÔNG BAO GIỜ dùng Transaction Script cho Core Subdomains!
4. Bản Ghi Hoạt Động (Active Record)
"Active Record là một đối tượng bao bọc lấy một hàng trong bảng hoặc khung nhìn của cơ sở dữ liệu, đóng gói quyền truy cập dữ liệu và bổ sung thêm logic miền nghiệp vụ trên dữ liệu đó."— Martin Fowler
4.1. Bản chất và Cấu trúc dữ liệu phức tạp
Tương tự như Transaction Script, Active Record phục vụ các trường hợp logic nghiệp vụ tương đối đơn giản. Tuy nhiên, điểm khác biệt là: Active Record giải quyết bài toán khi cấu trúc dữ liệu trở nên phức tạp hơn nhiều.
Thay vì các bản ghi phẳng đơn giản, hệ thống có thể chứa các cây đối tượng phức tạp với nhiều mối quan hệ một-nhiều (1-to-many), nhiều-nhiều (many-to-many) và các cấp bậc phân nhánh dữ liệu (xem Hình 5-4).
Nếu thao tác trên những cấu trúc dữ liệu chằng chịt này bằng Transaction Script thuần túy, lập trình viên sẽ phải viết đi viết lại mã đọc ghi SQL và mapping dữ liệu thủ công vào bộ nhớ ở khắp mọi ngóc ngách dự án.
4.2. Triển khai kỹ thuật & Tách biệt cấu trúc - hành vi
Để khắc phục sự trùng lặp đó, mẫu Active Record sử dụng các đối tượng chuyên trách (chính là các Active Records) để đại diện cho các cấu trúc dữ liệu phức tạp. Ngoài việc chứa dữ liệu, đối tượng này còn tích hợp sẵn các phương thức truy cập dữ liệu để thực hiện các thao tác CRUD (Create, Read, Update, Delete) — thường được gắn kết chặt chẽ với một framework ORM (Object-Relational Mapping).
Trong kiến trúc này, logic nghiệp vụ tổng thể của hệ thống vẫn được tổ chức dưới dạng Transaction Script. Điểm khác biệt duy nhất là: thay vì tự mình gọi SQL vào database, Transaction Script sẽ điều khiển và thao tác thông qua các đối tượng Active Record:
public class CreateUser
{
...
public void Execute(UserDetails userDetails)
{
try
{
_db.StartTransaction();
// Khởi tạo đối tượng Active Record
var user = new User();
user.Name = userDetails.Name;
user.Email = userDetails.Email;
// Đối tượng tự biết cách lưu trữ chính mình
user.Save();
_db.Commit();
}
catch
{
_db.Rollback();
throw;
}
}
}
Đặc điểm nhận dạng của Active Record: Đó là sự tách biệt giữa cấu trúc dữ liệu và hành vi (business logic). Các trường dữ liệu của Active Record thường có các getters và setters công khai (public), cho phép các thủ tục bên ngoài tự do đọc và sửa đổi trạng thái của nó.
4.3. Khi nào nên dùng Active Record & Phản biện "Anemic Domain Model"
Vì Active Record về bản chất là một Transaction Script được tối ưu hóa khả năng truy xuất cơ sở dữ liệu, nên nó chỉ phù hợp với các nghiệp vụ đơn giản (chủ yếu là CRUD kèm kiểm tra tính hợp lệ dữ liệu đầu vào - validation).
Cần phân biệt rõ: Active Record ở đây là Mẫu Thiết Kế (Design Pattern) do Martin Fowler đúc kết. Thư viện/framework Active Record trong Ruby on Rails hay các ORM khác ra đời sau này chỉ là một trong nhiều cách hiện thực hóa mẫu hình này trong thực tế.
5. Hãy Thực Dụng (Be Pragmatic)
Mặc dù dữ liệu kinh doanh là tài sản vô giá và mã nguồn của chúng ta phải ra sức bảo vệ tính toàn vẹn của nó, nhưng có những kịch bản mà tư duy thực dụng (pragmatic approach) lại là sự lựa chọn khôn ngoan hơn.
Đặc biệt ở quy mô dữ liệu khổng lồ (high levels of scale), đôi khi các bảo đảm nghiêm ngặt về tính nhất quán dữ liệu có thể được nới lỏng:
Hãy tự đặt câu hỏi: Liệu việc làm sai lệch trạng thái của đúng 1 bản ghi trong tổng số 1 triệu bản ghi có thực sự là yếu tố chí mạng đối với hoạt động kinh doanh hay không? Sự sai lệch nhỏ đó có làm giảm hiệu năng hay lợi nhuận của công ty không?
Ví dụ: Bạn đang xây dựng hệ thống tiếp nhận hàng tỷ sự kiện mỗi ngày từ các thiết bị IoT cảm biến thời tiết. Liệu việc thất thoát hoặc trùng lặp 0.001% số sự kiện đó có đáng để bạn bỏ ra hàng trăm nghìn USD xây dựng hạ tầng distributed transaction phức tạp gấp 10 lần? Câu trả lời hiển nhiên là không. Luôn cân nhắc giữa rủi ro kỹ thuật và giá trị thực tế của doanh nghiệp.
6. Tổng Kết Chương 5
Chương 5 đã mở màn cho Phần II: Thiết Kế Chiến Thuật bằng việc làm chủ hai mẫu hình cơ bản nhất:
- Transaction Script: Tổ chức thao tác thành các thủ tục tuần tự trực tiếp. Phải luôn bảo đảm hành vi giao dịch (transactional behavior) — thành công trọn vẹn hoặc thất bại sạch sẽ. Phù hợp cho Supporting Subdomains, luồng ETL, hoặc bộ chuyển đổi hệ thống bên ngoài.
- Active Record: Dành cho bài toán có logic nghiệp vụ đơn giản nhưng cấu trúc dữ liệu quan hệ phức tạp (1-N, N-N, cây phân cấp). Active Record bọc lấy hàng dữ liệu và cung cấp sẵn các thao tác CRUD.
- Nguyên tắc vàng: Cả Transaction Script lẫn Active Record đều KHÔNG ĐƯỢC PHÉP dùng cho Core Subdomains. Khi logic nghiệp vụ phức tạp xuất hiện, chúng ta bắt buộc phải sử dụng mẫu Domain Model — vũ khí tối thượng của DDD sẽ được khám phá chi tiết ở Chương 6!
7. Bài Tập & Trắc Nghiệm Đánh Giá Tri Thức (Exercises)
Hãy kiểm tra mức độ thấu hiểu các cạm bẫy giao dịch và kỹ thuật triển khai thông qua 4 bài tập thực chiến dưới đây. Đáp án và phân tích được đối chiếu trực tiếp từ tác giả Vlad Khononov (Appendix B).
Giải thích chi tiết (Appendix B): Cả Transaction Script và Active Record đều chỉ được thiết kế dành riêng cho các trường hợp logic nghiệp vụ tương đối đơn giản. Trong khi đó, các Core Subdomains vốn dĩ luôn đòi hỏi logic nghiệp vụ phức tạp, biến động cao và chứa đựng nhiều quy tắc bất biến nghiêm ngặt. Việc áp dụng hai mẫu này vào Core Subdomain sẽ nhanh chóng biến hệ thống thành một "Big Ball of Mud" không thể bảo trì.
public void CreateTicket(TicketData data)
{
// Dòng 06: Tìm agent rảnh nhất và tăng bộ đếm
var agent = FindLeastBusyAgent();
agent.ActiveTickets = agent.ActiveTickets + 1;
agent.Save();
// Dòng 12: Khởi tạo và lưu Ticket
var ticket = new Ticket();
ticket.Id = Guid.NewGuid();
ticket.Data = data;
ticket.AssignedAgent = agent;
ticket.Save();
// Dòng 14: Gửi thông báo
_alerts.Send(agent, "You have a new ticket!");
}
Giả định rằng không có bất kỳ cơ chế transaction cấp cao nào bao bọc đoạn mã này. Những sự cố bất nhất dữ liệu tiềm ẩn nào có thể xảy ra?
Giải thích chi tiết (Appendix B):
- Kịch bản A: Nếu lỗi xảy ra sau dòng 06, bên gọi (Caller) retry lại thao tác, và phương thức
FindLeastBusyAgent lại chọn đúng agent đó lần nữa -> biến đếm ActiveTickets của agent sẽ bị tăng 2 lần.- Kịch bản B: Nếu lỗi xảy ra sau dòng 06 nhưng Caller không retry -> biến đếm đã bị tăng vào database nhưng ticket thực tế ở dòng 12 chưa hề được tạo ra!
- Kịch bản C: Nếu lỗi xảy ra sau dòng 12 (ví dụ lỗi mạng khi gửi alert ở dòng 14) -> Ticket đã được tạo và gán thành công, nhưng thông báo không bao giờ đến được với agent.
CreateTicket ở trên, còn có ít nhất một trường hợp biên (edge case) nguy hiểm nào khác có thể phá hủy tính nhất quán của hệ thống nữa hay không?
Nếu việc thực thi bị lỗi hoặc mất kết nối ngay sau dòng 12 (ticket đã được lưu vào DB nhưng phản hồi chưa gửi về Caller) và bên gọi (Caller) tiến hành thử lại (retry) thao tác và lần này thành công: Chính chiếc ticket đó sẽ bị lưu vào database và gán hai lần thành hai bản ghi riêng biệt (Duplicate ticket)!
Tất cả các Supporting Subdomains (Miền con hỗ trợ) của WolfDesk đều là những ứng viên xuất sắc để triển khai bằng Transaction Script hoặc Active Record, bởi vì logic nghiệp vụ của chúng tương đối đơn giản và mang tính thủ tục trực diện:
- Quản lý danh mục ticket của khách thuê (Tenant's ticket categories): Thao tác CRUD đơn giản để thêm, sửa, xóa các danh mục phân loại hỗ trợ.
- Quản lý sản phẩm hỗ trợ (Tenant's products): Danh sách các sản phẩm mà khách hàng có thể mở ticket yêu cầu hỗ trợ.
- Nhập lịch làm việc của nhân viên hỗ trợ (Support agents' work schedules): Biểu mẫu nhập ca trực và thời gian làm việc hàng tuần.