Tác giả: Chris Woody Woodruff | Fractional Architect
Hãy tưởng tượng thế này. Bộ phận sản phẩm muốn một thay đổi nhỏ về chiết khấu: nếu tổng đơn hàng dưới 100, không được giảm giá. Bạn mở một endpoint, thêm một câu điều kiện, lưu và push. Mười phút, xong việc.
Ba tháng sau, quy tắc đơn giản đó tồn tại trong sáu endpoint khác nhau, mỗi cái lại có một biến thể nhỏ riêng. Ai đó sửa lỗi ở hai trong số chúng, quên mất những cái còn lại, và giờ không ai có thể trả lời một câu hỏi cơ bản: quy tắc chiết khấu thực sự trong hệ thống này là gì?
Mớ hỗn độn đang âm thầm lan rộng đó có một cái tên: Transaction Script.
Hầu hết các đội nhóm đều bắt đầu từ đây. Một số ở lại mãi mãi và trả giá cho điều đó trong mỗi lần release. Bài viết này sẽ đi qua Transaction Script thực sự là gì, nó trông như thế nào trong ASP.NET Core, điểm mạnh của nó, và cách nhận biết khi nào nó không còn là một lối tắt mà trở thành nợ cấu trúc (structural debt).
Bạn sẽ thấy các ví dụ C# về một Transaction Script thuần túy, tiếp theo là bước tái cấu trúc đầu tiên mở ra cánh cửa đến một thiết kế phong phú hơn.
Mục lục
Transaction Script Thực Sự Là Gì?
Transaction Script xử lý mỗi use case như một thủ tục đơn lẻ làm tất cả mọi việc:
- Đọc request
- Tải dữ liệu
- Áp dụng các quy tắc nghiệp vụ
- Lưu thay đổi
- Định hình response
Không có domain model riêng biệt với các hành vi, không có service layer chuyên biệt để điều phối các aggregate. Chỉ có một script cho mỗi kịch bản.
Pattern này không tự động sai. Nó cực kỳ đơn giản, điều này khiến nó mạnh mẽ trong bối cảnh phù hợp và nguy hiểm khi domain tiếp tục phát triển.
Ví Dụ Cụ Thể: Áp Dụng Chiết Khấu Dưới Dạng Transaction Script
Dưới đây là một Transaction Script thuần túy bên trong một endpoint Minimal API của ASP.NET Core.
app.MapPost("/discounts/apply", async (ApplyDiscountDto dto, AppDbContext db) =>
{
var customer = await db.Customers.FindAsync(dto.CustomerId);
if (customer == null)
{
return Results.NotFound("Customer not found.");
}
var order = await db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == dto.OrderId && o.CustomerId == dto.CustomerId);
if (order == null)
{
return Results.NotFound("Order not found.");
}
var total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);
if (total < 100)
{
return Results.BadRequest("Order total too low for discount.");
}
order.ApplyDiscount(dto.DiscountPercent);
await db.SaveChangesAsync();
return Results.Ok(new { order.Id, order.TotalAmount });
});
public record ApplyDiscountDto(Guid CustomerId, Guid OrderId, decimal DiscountPercent);
Mọi thứ đều sống trong một khối duy nhất:
- Truy cập dữ liệu qua AppDbContext
- Quy tắc nghiệp vụ về tổng tối thiểu
- Hành vi áp dụng chiết khấu
- HTTP responses và mã trạng thái
Đó là Transaction Script thuần túy: một thủ tục duy nhất xử lý toàn bộ transaction.
Khi Nào Transaction Script Hợp Lý?
Được sử dụng có chủ đích, Transaction Script phù hợp với nhiều tình huống thực tế.
Endpoint Nhỏ, Có Phạm Vi Hẹp
Nếu một tính năng có ranh giới rõ ràng và đơn giản, một script như thế này có thể là lý tưởng:
- Một endpoint quản trị để sửa một vấn đề dữ liệu cụ thể
- Một API nội bộ nhỏ cho công cụ có thể sẽ không bao giờ phát triển quá một vài thao tác
- Một tiện ích migration xử lý một file và ghi kết quả một lần
Trong những trường hợp này, việc tạo ra một domain model phong phú hoặc service layer cầu kỳ có thể là sự quá tải không cần thiết.
Tính Năng Ngắn Hạn và Thử Nghiệm
Đôi khi bạn cần một cuộc thử nghiệm nhanh (spike):
- Thử nghiệm một quy tắc chiết khấu mới để xem khách hàng phản hồi thế nào
- Xây dựng một endpoint nội bộ có thể sẽ được thay thế bằng hệ thống tốt hơn sau này
- Thu thập dữ liệu cho một chiến dịch tạm thời
Transaction Script cho phép bạn kết nối điều này một cách nhanh chóng. Nếu tính năng chết sớm, bạn không bao giờ phải trả một chi phí thiết kế lớn.
Đội Nhóm Dưới Áp Lực Giao Hàng Ngay Lập Tức
Có những thời điểm mà:
- Doanh nghiệp bị chặn cho đến khi một quy tắc cụ thể tồn tại
- Bạn đang ở giữa một sự cố và cần một giải pháp tạm thời nhanh
- Hệ thống còn sớm và các quy tắc domain vẫn còn hỗn loạn
Trong những điều kiện đó, một script được viết tốt có thể giữ cho hệ thống hoạt động trong khi bạn học hỏi ranh giới domain thực sự trông như thế nào.
Từ quan trọng ở đó là “trong khi”. Đến một lúc nào đó, domain ổn định. Nếu các script vẫn là yếu tố thiết kế chính, chúng trở thành những chiếc mỏ neo.
Cái Giá Thực Sự: Tự Lặp Lại Đến Ngõ Cụt
Ví dụ trên trông không đáng sợ. Rắc rối bắt đầu khi các yêu cầu phát triển.
Hãy tưởng tượng:
- Một quy tắc chiết khấu mới cho khách hàng VIP sử dụng ngưỡng thấp hơn
- Một use case khác áp dụng chiết khấu trong quá trình thanh toán, không chỉ qua endpoint này
- Một batch job đối soát chiết khấu vào cuối ngày
Mỗi lần bạn thêm một quy tắc dưới dạng script, bạn có một lựa chọn:
- Sao chép logic vào script mới
- Gọi script hiện có từ một nơi kỳ lạ nào đó
- Trích xuất một phần của nó vào một helper hoặc static method
Hầu hết các đội nhóm chọn con đường nhanh nhất, điều đó thường có nghĩa là sao chép, tinh chỉnh, lặp lại. Sau một vài lần lặp, bạn có:
- Các tổng tối thiểu hơi khác nhau rải rác trong các script
- Các câu điều kiện mà không ai dám động vào vì không chắc ai phụ thuộc vào chúng
- Logic nghiệp vụ chỉ có thể hiểu được bằng cách đọc từng dòng của nhiều endpoint
Ở giai đoạn đó, bạn không còn đang làm “mã thủ tục đơn giản” nữa. Bạn đang duy trì một domain model phân tán được tạo thành từ các mảnh vỡ trùng lặp.
Nhận Biết Khi Nào Các Script Của Bạn Đã Mất Kiểm Soát
Bạn không cần một thước đo phức tạp. Hãy tìm những tín hiệu thực tế sau đây.
Cùng Một Quy Tắc, Nhiều Nơi
Tìm kiếm literal chuỗi “Order total too low for discount”. Nếu bạn tìm thấy các biến thể của cùng một kiểm tra trong nhiều endpoint hoặc handler, bạn đã có một mùi khó chịu (code smell).
- Các ngưỡng khác nhau cho mỗi kịch bản, nhưng không có điểm quyết định trung tâm
- Các điều kiện biên được xử lý trong một số script, bị quên trong những script khác
Đó là dấu hiệu cho thấy khái niệm domain “điều kiện được chiết khấu” xứng đáng có một abstraction riêng.
Script Làm Tất Cả Mọi Thứ
Nếu một endpoint:
- Xác thực đầu vào
- Tải nhiều aggregate
- Tính tổng
- Áp dụng chiết khấu
- Ghi log kiểm toán
- Gửi thông báo
- Lưu thay đổi
Bạn không còn có một transaction script nữa… Bạn có một transaction tiểu thuyết. Thay đổi bất cứ thứ gì bên trong khối đó có nguy cơ gây ra các tác dụng phụ ở khắp mọi nơi.
Testing Trở Nên Đau Đớn
Nếu việc viết test cho hành vi chiết khấu của bạn:
- Yêu cầu khởi động một test server đầy đủ
- Yêu cầu gọi các HTTP route cho mọi biến thể
- Yêu cầu seeding một trạng thái database phức tạp liên tục
Thì các quy tắc đã quá gắn kết với infrastructure. Transaction Scripts đã nuốt chửng business logic của bạn.
Bước Tái Cấu Trúc Đầu Tiên: Giữ Lại Script, Trích Xuất Quy Tắc
Bạn không cần phải nhảy ngay từ Scripts sang một Domain Model đầy đủ. Bước đầu tiên có thể khiêm tốn: kéo các quy tắc cốt lõi vào một domain abstraction, để script ủy quyền.
Giới thiệu một domain service nhỏ biết cách hoạt động của chiết khấu.
public interface IDiscountPolicy
{
DiscountDecision Evaluate(decimal orderTotal, decimal requestedPercent);
}
public record DiscountDecision(bool IsAllowed, string? Reason);
public class MinimumTotalDiscountPolicy : IDiscountPolicy
{
private readonly decimal _minimumTotal;
public MinimumTotalDiscountPolicy(decimal minimumTotal)
{
_minimumTotal = minimumTotal;
}
public DiscountDecision Evaluate(decimal orderTotal, decimal requestedPercent)
{
if (orderTotal < _minimumTotal)
{
return new DiscountDecision(false, "Order total too low for discount.");
}
if (requestedPercent <= 0 || requestedPercent >= 50)
{
return new DiscountDecision(false, "Discount percent out of allowed range.");
}
return new DiscountDecision(true, null);
}
}
Bây giờ thay đổi script để sử dụng policy này.
app.MapPost("/discounts/apply", async (
ApplyDiscountDto dto,
AppDbContext db,
IDiscountPolicy discountPolicy) =>
{
var customer = await db.Customers.FindAsync(dto.CustomerId);
if (customer == null)
{
return Results.NotFound("Customer not found.");
}
var order = await db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == dto.OrderId && o.CustomerId == dto.CustomerId);
if (order == null)
{
return Results.NotFound("Order not found.");
}
var total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);
var decision = discountPolicy.Evaluate(total, dto.DiscountPercent);
if (!decision.IsAllowed)
{
return Results.BadRequest(decision.Reason);
}
order.ApplyDiscount(dto.DiscountPercent);
await db.SaveChangesAsync();
return Results.Ok(new { order.Id, order.TotalAmount });
});
Bạn vẫn có một Transaction Script. Nó vẫn điều phối transaction. Nhưng có một điều quan trọng đã thay đổi.
- Quy tắc về việc chiết khấu có được phép hay không giờ đây sống ở một nơi
- Các script khác hoặc background jobs có thể tái sử dụng IDiscountPolicy
- Bạn có thể unit test policy với các plain C# tests mà không cần test server hay database
Đây là bản lề nơi hệ thống của bạn có thể tiến tới một Domain Model hoặc một Service Layer có cấu trúc hơn.
Khi Nào Transaction Script Nên Nhường Chỗ Cho Các Pattern Khác
Một khi một quy tắc trở thành trung tâm của sản phẩm, bạn cần nhiều hơn một thủ tục đơn giản.
Domain Model Cho Các Quy Tắc Phức Tạp, Kết Nối Với Nhau
Nếu chiết khấu:
- Phụ thuộc vào trạng thái khách hàng, danh mục sản phẩm, thời gian trong ngày và quy tắc chiến dịch
- Tương tác với điểm trung thành, tính thuế và xử lý đơn hàng
- Yêu cầu xác thực và mô phỏng tinh vi
Thì một Domain Model cung cấp cho bạn một nơi để thể hiện các mối quan hệ đó. Aggregates, value objects và domain services có thể nắm bắt các quy tắc đó theo cách mà scripts không bao giờ làm được.
Trong thế giới đó, logic chiết khấu có thể sống bên trong một Order aggregate hoặc một DiscountEngine chuyên biệt, được kiểm tra trực tiếp và sử dụng từ nhiều entry points.
Table Module Cho Các Thao Tác Tập Trung Vào Bảng
Nếu hầu hết logic hoạt động trên một bảng hoặc view duy nhất, như OrderSummary hoặc InvoiceRow, và các thao tác hướng đến tập hợp (set-oriented), một Table Module có thể là đủ.
Một lớp DiscountTableModule đóng gói các truy vấn và cập nhật cho các trường chiết khấu có thể tập trung hóa hành vi mà không cần phát minh ra một object model đầy đủ.
Điểm mấu chốt không phải là tên của pattern. Đó là liệu các quy tắc có sống ở một nơi duy nhất, có thể kiểm tra được, thay vì nằm rải rác trong các mảnh vụn hay không.
Hướng Dẫn Thực Tế Cho Các Đội .NET
Nếu bạn xây dựng hệ thống ASP.NET Core, bạn có thể coi Transaction Script như một công cụ được kiểm soát thay vì một mặc định phản xạ.
- Sử dụng scripts cho các use case biệt lập với quy tắc đơn giản và vòng đời ngắn
- Để ý sự trùng lặp: một khi một quy tắc xuất hiện trong nhiều hơn một script, hãy trích xuất nó vào một domain component
- Giữ orchestration và infrastructure trong script, đẩy các quyết định domain vào services hoặc entities
- Thêm tests xung quanh các phần domain đã được trích xuất trước khi bạn chạm vào scripts một lần nữa
Mục tiêu không phải là cấm Transaction Script. Mục tiêu là tránh thức dậy một ngày trong một codebase nơi mọi quy tắc quan trọng đều sống bên trong một endpoint chưa được kiểm tra.
Một Thử Thách Cho Codebase Hiện Tại Của Bạn
Hãy chọn một tính năng ngay hôm nay:
- Tìm một endpoint hoặc handler đọc từ database, áp dụng một quy tắc và ghi lại trong một method duy nhất.
- Xác định một quy tắc duy nhất bên trong method đó mà những người làm business quan tâm.
- Trích xuất quy tắc đó vào một domain service hoặc helper với unit tests riêng của nó.
- Giữ nguyên phần còn lại của script, để endpoint gọi abstraction mới.
Chạy tests. Chỉ ra rằng không có gì thay đổi trong hành vi. Sau đó tự hỏi: nếu một lần trích xuất nhỏ này đã làm cho quy tắc rõ ràng hơn, điều gì sẽ xảy ra khi bạn lặp lại động tác đó trên các phần của hệ thống đau đớn nhất?
Đó là cách Transaction Script biến từ một kiến trúc vĩnh viễn thành một bước đệm.



