5.10 - Case study: refactor hàm tính giảm giá
Tóm tắt: Một
switch8 nhánh trong hàm tính giảm giá, mỗi lần thêm chương trình khuyến mãi là sửa lại hàm đó — vi phạm rõ ràng nguyên tắc mở/đóng. Bài này áp dụng SOLID để biến nó thành hệ thống thêm mới không sửa cũ, và dùng lại gần hết công cụ của Module 5: interface vàIEnumerable<T>để giữ danh sách chiến lược, LINQ để chọn khuyến mãi có lợi nhất, nullable reference types cho hạng khách hàng có thể chưa có,recordcho kết quả, vàFunccho phương án trung gian. Phần có giá trị hơn nằm ở nửa sau: khi nào SOLID bị lạm dụng. Áp dụng máy móc, cùng bài toán này có thể sinh ra 12 interface và 15 lớp cho việc mà 20 dòngswitchlàm được — và đó không phải thiết kế tốt hơn.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nhận ra vi phạm nguyên tắc mở/đóng trong code thật.
- Refactor một
switchlớn thành tập hợp chiến lược, chạy được bằng một console app. - Dùng LINQ, nullable reference types và
recordtrong một thiết kế hoàn chỉnh. - Chọn giữa
switch, từ điểnFuncvà lớp chiến lược theo mức độ thay đổi. - Nhận ra khi SOLID đang bị lạm dụng.
Cần biết trước: bài 5.1 (SRP, OCP), bài 5.3 (LINQ), bài 5.4 (Func), bài 5.6 (nullable reference types), bài 4.7 (record).
Nội dung bài học
5.10.1 — Code ban đầu
Tình huống: hàm tính giảm giá của một hệ thống bán hàng gắn với CRM. Mô hình dữ liệu dùng xuyên suốt bài:
// Order.cs
public record Customer(string Name, string? Tier); // Tier null = khách chưa có hạng
public record OrderLine(string Product, decimal UnitPrice, int Quantity);
public record Order(Customer Customer, IReadOnlyList<OrderLine> Lines, decimal ShippingFee, string? VoucherCode = null)
{
public decimal Total => Lines.Sum(l => l.UnitPrice * l.Quantity);
}
public enum PromotionType { Percentage, FixedAmount, Loyalty, FreeShipping, Voucher }Và hàm tính giảm giá ban đầu:
public decimal CalculateDiscount(Order order, PromotionType type)
{
switch (type)
{
case PromotionType.Percentage:
return order.Total >= 500_000m ? order.Total * 0.1m : 0m;
case PromotionType.FixedAmount:
return 50_000m;
case PromotionType.Loyalty:
return order.Customer.Tier switch
{
"Gold" => order.Total * 0.15m,
"Silver" => order.Total * 0.1m,
_ => 0m
};
case PromotionType.FreeShipping:
return order.ShippingFee;
// ... 4 case nữa, mỗi case vài chục dòng
default:
return 0m;
}
}Những gì đội ghi nhận được về hàm này:
- Mỗi chương trình khuyến mãi mới: sửa hàm này (đã 8 lần trong 6 tháng)
- Độ dài: 187 dòng
- Độ phụ thuộc của lớp chứa nó: 7 dependency (vì mỗi loại cần thứ khác nhau)
- Số test cho hàm này: 1 test file, 340 dòng, 24 trường hợpVấn đề lớn nhất không phải độ dài mà là mỗi thay đổi đều chạm vào code đang chạy đúng. Thêm một khuyến mãi mới có thể làm hỏng bảy cái cũ.
5.10.2 — Áp dụng nguyên tắc mở/đóng
Mở cho mở rộng, đóng cho sửa đổi: thêm hành vi mới bằng cách thêm code, không phải sửa code cũ. Mỗi loại khuyến mãi thành một lớp cài cùng một interface:
public interface IDiscountStrategy
{
PromotionType Type { get; }
bool AppliesTo(Order order);
decimal CalculateDiscount(Order order);
}public sealed class PercentageDiscount(decimal percentage, decimal minimumTotal) : IDiscountStrategy
{
public PromotionType Type => PromotionType.Percentage;
public bool AppliesTo(Order order) => order.Total >= minimumTotal;
public decimal CalculateDiscount(Order order) => order.Total * percentage;
}
public sealed class LoyaltyDiscount(IReadOnlyDictionary<string, decimal> tierRates) : IDiscountStrategy
{
public PromotionType Type => PromotionType.Loyalty;
public bool AppliesTo(Order order)
=> order.Customer.Tier is { } tier && tierRates.ContainsKey(tier);
public decimal CalculateDiscount(Order order)
=> order.Customer.Tier is { } tier && tierRates.TryGetValue(tier, out var rate)
? order.Total * rate
: 0m;
}Customer.Tier khai là string? vì khách mới chưa có hạng. Pattern Tier is { } tier vừa kiểm tra khác null vừa đặt tên cho giá trị, nên trình biên dịch không cảnh báo và không cần toán tử ! (bài 5.6).
Mỗi lớp chỉ có một lý do để thay đổi — đúng nguyên tắc trách nhiệm đơn nhất. PercentageDiscount chỉ đổi khi quy tắc phần trăm đổi, và nó chỉ cần đúng dữ liệu của nó thay vì bảy dependency.
5.10.3 — Chọn chiến lược
public sealed class DiscountService(IEnumerable<IDiscountStrategy> strategies)
{
public decimal CalculateDiscount(Order order, PromotionType type)
{
var strategy = strategies.FirstOrDefault(s => s.Type == type);
if (strategy is null || !strategy.AppliesTo(order))
return 0m;
return strategy.CalculateDiscount(order);
}
// Chọn chương trình có lợi nhất cho khách — TRƯỚC ĐÂY không làm được
public DiscountResult FindBestDiscount(Order order)
=> strategies
.Where(s => s.AppliesTo(order))
.Select(s => new DiscountResult(s.Type, s.CalculateDiscount(order)))
.MaxBy(r => r.Amount)
?? new DiscountResult(null, 0m);
}
public record DiscountResult(PromotionType? Type, decimal Amount);MaxBy trả về phần tử có Amount lớn nhất, hoặc null nếu không chiến lược nào áp dụng được; ?? thay null bằng kết quả "không giảm". Kết quả trả ra khỏi lớp nên dùng record có tên, không dùng tuple (bài 5.8).
Chương trình đầy đủ dưới đây chạy được bằng dotnet run. Nó gồm mô hình ở mục 5.10.1, IDiscountStrategy, PercentageDiscount, LoyaltyDiscount (mục 5.10.2), DiscountService, DiscountResult (ở trên), và hai chiến lược ngắn sau:
// FixedAmountDiscount.cs
public sealed class FixedAmountDiscount(decimal amount) : IDiscountStrategy
{
public PromotionType Type => PromotionType.FixedAmount;
public bool AppliesTo(Order order) => order.Total > amount;
public decimal CalculateDiscount(Order order) => amount;
}
// FreeShippingDiscount.cs
public sealed class FreeShippingDiscount : IDiscountStrategy
{
public PromotionType Type => PromotionType.FreeShipping;
public bool AppliesTo(Order order) => order.ShippingFee > 0;
public decimal CalculateDiscount(Order order) => order.ShippingFee;
}Code chạy tạo các chiến lược bằng tay và truyền vào DiscountService; trong ASP.NET Core, DI container làm việc đó và tự gom mọi lớp đăng ký cùng interface thành IEnumerable<IDiscountStrategy> (bài 7.5).
// Program.cs
IDiscountStrategy[] strategies =
[
new PercentageDiscount(percentage: 0.10m, minimumTotal: 500_000m),
new FixedAmountDiscount(amount: 50_000m),
new LoyaltyDiscount(new Dictionary<string, decimal> { ["Gold"] = 0.15m, ["Silver"] = 0.10m }),
new FreeShippingDiscount(),
];
var service = new DiscountService(strategies);
var order = new Order(
new Customer("Công ty An Phát", Tier: "Gold"),
[new OrderLine("Bàn phím", 450_000m, 2), new OrderLine("Chuột", 150_000m, 1)],
ShippingFee: 30_000m);
Console.WriteLine($"Tổng đơn: {order.Total:N0}");
foreach (var type in Enum.GetValues<PromotionType>())
Console.WriteLine($"{type,-13}: {service.CalculateDiscount(order, type):N0}");
var best = service.FindBestDiscount(order);
Console.WriteLine($"Có lợi nhất: {best.Type} giảm {best.Amount:N0}");
var guest = order with { Customer = new Customer("Khách lẻ", Tier: null) };
Console.WriteLine($"Khách lẻ, có lợi nhất: {service.FindBestDiscount(guest)}");Dán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).
Kết quả chạy trên .NET 10:
Tổng đơn: 1.050.000
Percentage : 105.000
FixedAmount : 50.000
Loyalty : 157.500
FreeShipping : 30.000
Voucher : 0
Có lợi nhất: Loyalty giảm 157.500
Khách lẻ, có lợi nhất: DiscountResult { Type = Percentage, Amount = 105000,00 }Voucher ra 0 vì chưa có chiến lược nào cho nó — Bài tập 1 sẽ thêm. Với khách lẻ (Tier là null), LoyaltyDiscount tự loại mình qua AppliesTo và chương trình chọn khuyến mãi phần trăm.
Phương thức FindBestDiscount đáng chú ý: với switch cũ, viết được nó nghĩa là phải lặp qua mọi giá trị enum và gọi lại hàm 8 lần. Với thiết kế mới, nó là một chuỗi LINQ — một khả năng mới xuất hiện nhờ cấu trúc, không phải nhờ viết thêm logic.
5.10.4 — Phương án trung gian: từ điển Func
Giữa switch và một lớp cho mỗi chiến lược có một mức ở giữa: khi mỗi khuyến mãi chỉ là một công thức ngắn, không cần dependency riêng, một từ điển Func<Order, decimal> (bài 5.4) là đủ:
var rules = new Dictionary<PromotionType, Func<Order, decimal>>
{
[PromotionType.Percentage] = o => o.Total >= 500_000m ? o.Total * 0.10m : 0m,
[PromotionType.FixedAmount] = o => 50_000m,
[PromotionType.FreeShipping] = o => o.ShippingFee,
};
decimal Discount(Order o, PromotionType t) => rules.TryGetValue(t, out var rule) ? rule(o) : 0m;Thêm khuyến mãi là thêm một dòng vào từ điển, không đụng vào các dòng khác. Khi một quy tắc bắt đầu cần dependency (bảng hạng khách hàng, kho voucher) hoặc dài hơn vài dòng, chuyển riêng nó thành lớp chiến lược.
switch | Từ điển Func | Lớp chiến lược | |
|---|---|---|---|
| Chi phí viết | Thấp nhất | Thấp | Một lớp mỗi loại |
| Mỗi loại có dependency riêng | Lớp chứa gánh hết | Khó | Dễ |
| Test riêng từng loại | Khó | Được | Dễ |
| Hợp khi | Số loại cố định, logic ngắn | Logic ngắn, hay thêm loại | Logic dài, dependency khác nhau |
5.10.5 — Kết quả
| Chỉ số | Trước | Sau |
|---|---|---|
| Dòng trong một file | 187 | 10–40 mỗi chiến lược |
| Dependency của lớp điều phối | 7 | 1 (danh sách chiến lược) |
| Thêm khuyến mãi mới | Sửa file đang chạy đúng | Thêm file mới |
| Test cho một chiến lược | Phải dựng 7 dependency | Chỉ dữ liệu của chính nó |
| Chọn khuyến mãi tốt nhất | Không làm được | Một chuỗi LINQ |
Hàng thứ tư là lợi ích lớn nhất trong thực tế: test của PercentageDiscount không còn quan tâm tới bảng hạng khách hàng hay phí vận chuyển.
5.10.6 — Khi SOLID bị lạm dụng
Cùng bài toán, áp dụng máy móc có thể ra thế này:
// LẠM DỤNG
public interface IDiscountStrategy { }
public interface IConditionalDiscountStrategy : IDiscountStrategy { }
public interface IDiscountStrategyFactory { }
public interface IDiscountCoordinator { }
public interface IDiscountValidator { }
public interface IDiscountFormatter { }
public interface IDiscountLogger { }
public abstract class DiscountStrategyBase { }
public class DefaultDiscountCoordinator : IDiscountCoordinator { }
// ... 15 lớp cho việc mà 20 dòng switch làm đượcĐây không phải thiết kế tốt hơn — nó là chi phí thuần. Người đọc phải nhảy qua 6 file để hiểu một phép tính.
Hai câu hỏi phân biệt:
1. Phần này có thật sự thay đổi thường xuyên không?
Trong ca này: có — 8 lần trong 6 tháng. Đó là bằng chứng, không phải phỏng đoán.
Nếu một switch 3 nhánh chưa đổi lần nào trong hai năm, tách nó ra là giải quyết vấn đề không tồn tại.
2. Abstraction này có ít nhất hai cài đặt thật không?
Interface có một cài đặt duy nhất và không có kế hoạch thêm cái thứ hai là abstraction không cần thiết. Nó chỉ thêm một lớp gián tiếp.
// Không cần — chỉ có một cài đặt, và sẽ không có cái thứ hai
public interface IDateFormatter { string Format(DateTime d); }
public class DefaultDateFormatter : IDateFormatter { }Ngoại lệ hợp lệ: bạn cần interface để test thay thế được, ví dụ IEmailSender. Đó là cài đặt thứ hai thật (bản giả dùng trong test).
Quy tắc thực dụng: viết code đơn giản nhất chạy được, và tách ra khi bạn chạm vào nó lần thứ ba (bài 16.14).
5.10.7 — Rà lại code của bạn
Danh sách rà soát áp dụng SOLID
- Có bằng chứng phần code này thay đổi thường xuyên trước khi tách.
- Mỗi interface có ít nhất hai cài đặt thật, hoặc cần cho test.
- Mỗi lớp chỉ có một lý do để thay đổi.
- Thêm hành vi mới là thêm file, không sửa file đang chạy đúng.
- Lớp điều phối không cần biết chi tiết của từng cài đặt.
- Test của một cài đặt không phải dựng dependency của cài đặt khác.
- Số file phải mở để hiểu một luồng không quá ba.
Bài tập áp dụng
Bài 1 — Thêm chiến lược
Thêm "giảm giá theo mã voucher" vào chương trình ở mục 5.10.3 và đếm số file phải sửa. Voucher có mã, giá trị (số tiền hoặc phần trăm), ngày hết hạn, số lần đã dùng và giới hạn số lần dùng.
Tiêu chí hoàn thành: chạy lại chương trình, dòng Voucher ra số khác 0 với một đơn có mã voucher hợp lệ; bạn đếm được số file cho cả hai thiết kế, và nhận ra con số 0 file logic hiện có phải sửa mới là điều nguyên tắc mở/đóng hứa hẹn.
Gợi ý và lời giải — Bài 1
Gợi ý. Đếm riêng "file thêm mới" và "file hiện có phải sửa". Chỉ con số thứ hai mới quan trọng. Kiểm tra hết hạn cần biết "bây giờ là mấy giờ": nhận một TimeProvider (lớp có sẵn từ .NET 8) qua constructor thay vì gọi thẳng DateTimeOffset.UtcNow, để test điều khiển được thời gian.
Lời giải — với thiết kế chiến lược:
// File MỚI: VoucherDiscount.cs
public record Voucher(string Code, decimal Value, bool IsPercentage,
DateTimeOffset ExpiresAt, int UsedCount, int UsageLimit);
public interface IVoucherRepository { Voucher? Find(string code); }
public sealed class VoucherDiscount(IVoucherRepository vouchers, TimeProvider clock) : IDiscountStrategy
{
public PromotionType Type => PromotionType.Voucher;
public bool AppliesTo(Order order) => order.VoucherCode is not null;
public decimal CalculateDiscount(Order order)
{
if (order.VoucherCode is null) return 0m;
var v = vouchers.Find(order.VoucherCode);
if (v is null || v.ExpiresAt < clock.GetUtcNow() || v.UsedCount >= v.UsageLimit)
return 0m;
return v.IsPercentage
? order.Total * v.Value
: Math.Min(v.Value, order.Total);
}
}
// Bản lưu trong bộ nhớ để chạy thử; bản thật đọc từ database
public sealed class InMemoryVoucherRepository : IVoucherRepository
{
private readonly Dictionary<string, Voucher> _vouchers = new()
{
["GIAM100K"] = new("GIAM100K", 100_000m, false,
new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero), 3, 100),
};
public Voucher? Find(string code) => _vouchers.GetValueOrDefault(code);
}// Program.cs — thêm MỘT dòng vào danh sách chiến lược, và mã voucher cho đơn hàng
new VoucherDiscount(new InMemoryVoucherRepository(), TimeProvider.System),
// ...
ShippingFee: 30_000m,
VoucherCode: "GIAM100K");Chạy lại:
Voucher : 100.000File thêm mới : 1 (cộng 1 file test)
File hiện có phải sửa: 1 (chỉ chỗ tạo danh sách chiến lược, hoặc dòng đăng ký DI)
DiscountService : KHÔNG SỬA
Các chiến lược cũ : KHÔNG SỬA
Test của chúng : KHÔNG SỬA, và vẫn xanhPromotionType.Voucher và Order.VoucherCode đã có sẵn trong mô hình ở mục 5.10.3. Nếu chưa có, bạn sẽ phải sửa thêm enum và record Order — đó là thay đổi dữ liệu, không phải thay đổi logic của các chiến lược cũ.
Với thiết kế switch ban đầu:
1. Thêm giá trị vào enum PromotionType
2. Thêm một case vào hàm 187 dòng -> giờ thành 210 dòng
3. Thêm hai dependency (IVoucherRepository, TimeProvider) vào lớp chứa
-> lớp đó từ 7 lên 9 dependency
4. Sửa constructor -> mọi chỗ tạo lớp đó phải sửa theo
5. File test 340 dòng phải sửa và chạy lại toàn bộ 24 trường hợpFile hiện có phải sửa: 4–5
Và mọi test của 7 khuyến mãi cũ phải chạy lại — vì code của chúng
nằm trong CÙNG một hàm vừa bị sửa.Bảng so sánh:
| Chiến lược | switch | |
|---|---|---|
| File mới | 2 | 0 |
| File hiện có phải sửa | 1 | 4–5 |
| Code đang chạy đúng bị chạm tới | Không | Có |
| Dependency của lớp lớn nhất | không đổi | 7 → 9 |
| Phải chạy lại test của khuyến mãi cũ | Không | Có |
Và đây là điều nguyên tắc mở/đóng thật sự hứa hẹn:
Không phải "ít code hơn" — bản chiến lược có nhiều file hơn.
Mà là: thêm hành vi mới KHÔNG chạm vào hành vi cũ
-> rủi ro của một thay đổi không còn tỉ lệ với số tính năng đã cóVới switch: tính năng thứ 20 vẫn sửa cùng một hàm với tính năng thứ 1
-> mỗi lần thêm, xác suất làm hỏng cái cũ TĂNG
Với chiến lược: tính năng thứ 20 là một file độc lập
-> rủi ro giữ nguyên, không tích luỹMột chi tiết đáng chú ý trong cài đặt VoucherDiscount: nó cần 2 dependency mà không chiến lược nào khác cần.
Với switch: lớp chứa phải nhận CẢ 9 dependency, dù mỗi lần gọi
chỉ dùng một hai cái
-> và test phải giả lập cả 9, kể cả khi chỉ kiểm tra khuyến mãi phần trăm
Với chiến lược: mỗi lớp nhận đúng thứ nó cần
-> test VoucherDiscount giả lập 2 thứ, không phải 9Đây là lý do bài 2 dưới đây đo số dependency: nó là chỉ số dễ đếm nhất cho thấy một lớp đang gánh quá nhiều trách nhiệm.
Và một cảnh báo để cân bằng — đừng áp dụng mẫu này cho mọi switch:
// switch này KHÔNG cần chiến lược
public string GetStatusLabel(LeadStatus status) => status switch
{
LeadStatus.New => "Mới",
LeadStatus.InProgress => "Đang xử lý",
LeadStatus.Won => "Đã chốt",
_ => "Không rõ",
};Ba dấu hiệu cho thấy switch NÊN thành chiến lược:
1. Mỗi nhánh cần dependency KHÁC NHAU
2. Danh sách nhánh còn dài ra theo thời gian
3. Logic mỗi nhánh dài hơn vài dòng
Thiếu cả ba -> switch là lựa chọn đúng, và ngắn hơn nhiều.Bài 2 — Đo dependency
Lớp dưới đây có 9 dependency (các interface trong constructor) và 6 phương thức công khai. Cột bên phải ghi phương thức nào dùng dependency nào. Quyết định có nên tách lớp này không, và nếu tách thì tách theo đâu.
public sealed class OrderService(
CrmDbContext db, IPromotionService promotions, IInventory inventory,
IPaymentGateway payments, IEmailSender email, ISmsSender sms,
IPdfRenderer pdf, IFileStorage storage, ILogger<OrderService> logger)
{
public Order Create(...) // dùng db, promotions, inventory
public void Confirm(...) // dùng db, payments, email
public void Cancel(...) // dùng db, payments
public Order? Get(...) // dùng db
public byte[] ExportInvoice(...)// dùng db, pdf, storage
public void SendReminder(...) // dùng db, email, sms
}Đã có code của riêng mình? Làm thêm với lớp service dài nhất trong đó.
Tiêu chí hoàn thành: bạn đếm được mỗi phương thức dùng bao nhiêu trong 9 dependency, và dùng một câu hỏi để quyết định tách hay không — thay vì dựa vào một ngưỡng cứng.
Gợi ý và lời giải — Bài 2
Gợi ý. Số dependency không tự nó là vấn đề. Vấn đề là mỗi phương thức dùng bao nhiêu trong số đó.
Lời giải.
OrderService — 9 dependency, 6 phương thức công khai:
Create dùng db, promotions, inventory (3/9)
Confirm dùng db, payments, email (3/9)
Cancel dùng db, payments (2/9)
Get dùng db (1/9)
ExportInvoice dùng db, pdf, storage (3/9)
SendReminder dùng db, email, sms (3/9)Mỗi phương thức dùng 1–3 trong số 9 (logger dùng ở đâu cũng được, không tính).
Không phương thức nào dùng quá một phần ba.Đây là dấu hiệu rõ nhất — và nó có tên: độ gắn kết thấp.
Một lớp gắn kết CAO: mọi phương thức dùng phần lớn các trường
-> chúng thuộc về nhau
Một lớp gắn kết THẤP: mỗi phương thức dùng một tập con rời nhau
-> đây là nhiều lớp đang nằm chung một fileTách theo đúng các tập con đó:
// Create + Confirm + Cancel — vòng đời đơn hàng
public sealed class OrderWorkflowService(
CrmDbContext db, IPromotionService promotions, IInventory inventory,
IPaymentGateway payments, IEmailSender email); // 5 dep
// ExportInvoice
public sealed class InvoiceExportService(CrmDbContext db, IPdfRenderer pdf, IFileStorage storage); // 3 dep
// SendReminder
public sealed class OrderReminderService(CrmDbContext db, IEmailSender email, ISmsSender sms); // 3 dep
// Get -> đọc thẳng qua DbContext ở nơi cần, không cần service riêngTrước: 1 lớp, 9 dependency
Sau : 3 lớp, 3–5 dependency mỗi lớpVà đây là câu hỏi quyết định, thay cho mọi ngưỡng cứng:
"Có thay đổi nào chỉ chạm vào MỘT phần của lớp này không?"
Có -> phần đó nên là một lớp riêng
Không -> lớp đang gắn kết tốt, kể cả khi nó có 8 dependencyVí dụ cụ thể: đổi nhà cung cấp PDF
-> chỉ chạm ExportInvoice
-> nhưng phải mở một lớp có 9 dependency
-> và mọi test của 5 phương thức kia phải chạy lạiVì sao ngưỡng cứng ("quá 5 dependency là xấu") không dùng được:
// 8 dependency, nhưng MỌI phương thức đều dùng gần hết -> gắn kết cao
public sealed class PaymentProcessingService(
CrmDbContext db, IPaymentGateway gateway, IFraudDetector fraud,
IExchangeRateProvider rates, IInvoiceService invoices, INotifier notifier,
ILogger<PaymentProcessingService> logger, TimeProvider clock);Một luồng thanh toán thật sự cần cả 8 thứ đó, ở gần như mọi bước.
Tách ra sẽ tạo ra các lớp phải truyền dữ liệu qua lại — tệ hơn.Ba chỉ số nên đo cùng nhau, không đo riêng:
| Chỉ số | Cảnh báo khi | Nhưng kiểm tra thêm |
|---|---|---|
| Số dependency | > 6 | Mỗi phương thức dùng bao nhiêu? |
| Số dòng | > 300 | Có nhóm phương thức rời nhau không? |
| Số phương thức công khai | > 8 | Chúng có chung dữ liệu không? |
Cả ba cùng cao -> gần như chắc chắn nên tách
Chỉ một cái cao -> xem xét, đừng tự động táchVisual Studio có tính năng Code Metrics (Analyze > Calculate Code Metrics) tính sẵn số dòng, độ phức tạp và mức phụ thuộc cho từng lớp — hữu ích khi cần rà nhiều lớp cùng lúc.
Bài 3 — Tìm abstraction thừa
Một dự án CRM có năm interface, mỗi cái chỉ có một lớp cài đặt và chưa được dùng trong test nào:
ICustomerMapper chuyển Customer sang CustomerDto, chỉ gán thuộc tính
ILeadValidator kiểm tra tên, email, giá trị của lead
IOrderCalculator tính tổng tiền đơn hàng từ các dòng
IEmailTemplateEngine đọc file mẫu email từ đĩa rồi thay biến
IReportExporter ghi báo cáo ra kho lưu trữ ngoài; tháng sau sẽ thêm định dạng ExcelVới mỗi interface, quyết định giữ hay bỏ, kèm lý do.
Tiêu chí hoàn thành: bạn có danh sách kèm đánh giá, và phân biệt được interface thừa với interface có lý do dù chỉ một cài đặt.
Gợi ý và lời giải — Bài 3
Gợi ý. "Một cài đặt" không tự nó là bằng chứng thừa. Câu hỏi thật là: interface này đang giúp gì?
Lời giải — đánh giá từng cái bằng bốn câu hỏi:
1. Có ranh giới ra ngoài tiến trình không? (mạng, đĩa, đồng hồ, ngẫu nhiên)
2. Có kế hoạch cụ thể cho cài đặt thứ hai không?
3. Nó có làm test của lớp KHÁC dễ hơn không?
4. Nó có phải là hợp đồng công khai của một module không?| Interface | Đánh giá | Lý do |
|---|---|---|
ICustomerMapper | Thừa | Ánh xạ thuần, không I/O, test được trực tiếp |
ILeadValidator | Thừa | Hàm thuần, gọi thẳng là được |
IOrderCalculator | Thừa | Tính toán thuần |
IEmailTemplateEngine | Giữ | Đọc template từ đĩa — có I/O |
IReportExporter | Giữ | Ghi ra lưu trữ ngoài, và có kế hoạch thêm định dạng |
Ba interface "thừa" có cái giá thật:
Mỗi interface thừa là:
- một file nữa phải mở khi lần theo code
- một bước nhảy nữa trong IDE (F12 vào interface, rồi tìm cài đặt)
- một dòng đăng ký DI nữa
- và một lời hứa sai: "chỗ này có thể thay thế được"
trong khi không ai có ý định thay// Trước
public interface IOrderCalculator { decimal CalculateTotal(Order order); }
public sealed class OrderCalculator : IOrderCalculator { ... }
builder.Services.AddScoped<IOrderCalculator, OrderCalculator>();
// Sau — một lớp, không interface
public static class OrderCalculator
{
public static decimal CalculateTotal(Order order) => order.Lines.Sum(l => l.UnitPrice * l.Quantity);
}Test vẫn viết được bình thường — nó là hàm thuần, gọi thẳng và kiểm tra kết quả.
Không cần giả lập gì cả, nên không cần interface để giả lập.Và hai interface "giữ" — vì sao một cài đặt vẫn đáng:
IEmailTemplateEngine đọc file từ đĩa.
-> test của LeadService muốn kiểm tra "có gửi email không"
mà KHÔNG muốn chạm đĩa
-> nên nó cần giả lập -> nên nó cần interface
Tiêu chí: interface tồn tại để TÁCH RA NGOÀI TIẾN TRÌNH, không phải để
"trừu tượng hoá cho đẹp".Một trường hợp thứ ba, không thuộc hai nhóm trên:
public interface IClock { DateTimeOffset Now { get; } }Một cài đặt, và nó chỉ bọc DateTimeOffset.UtcNow.
Nhưng nó cho phép test kiểm soát thời gian — điều không làm được
với DateTimeOffset.UtcNow gọi trực tiếp.
-> GIỮ, dù nhỏ tới mức trông như thừa.// .NET 8 trở lên có sẵn TimeProvider, không cần tự viết
public class LeadService(TimeProvider clock)
{
public Lead Create(string name) => new(name, clock.GetUtcNow());
}
// Trong test: FakeTimeProvider nằm trong gói Microsoft.Extensions.TimeProvider.Testing (MIT)
var fakeClock = new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 10, 0, 0, TimeSpan.Zero));Bài 1 ở trên cũng dùng đúng cách này: VoucherDiscount nhận TimeProvider.
Quy tắc thực dụng, gói gọn:
Tạo interface khi có LÝ DO CỤ THỂ:
- ranh giới ra ngoài tiến trình cần giả lập trong test
- đã có, hoặc chắc chắn sẽ có, cài đặt thứ hai
- là hợp đồng công khai giữa hai module
Không tạo interface vì:
- "sau này có thể cần"
- "để theo nguyên tắc đảo ngược phụ thuộc"
- "vì mọi service khác đều có"Đây là điều mục 5.10.6 nói về việc lạm dụng SOLID: nguyên tắc đảo ngược phụ thuộc nói phụ thuộc vào trừu tượng ở ranh giới, không nói mọi lớp phải có một interface. Ba interface bị xoá ở trên không vi phạm nguyên tắc nào — chúng chỉ đang trả chi phí cho một lợi ích không tồn tại.
Tự kiểm tra
Vấn đề lớn nhất của switch 8 nhánh là gì?
Không phải độ dài, mà là mỗi thay đổi đều chạm vào code đang chạy đúng. Thêm một khuyến mãi mới có thể làm hỏng bảy cái cũ, và test phải dựng mọi dependency của mọi nhánh.
Nguyên tắc mở đóng nghĩa là gì trong ca này?
Thêm hành vi mới bằng cách thêm một lớp chiến lược và một dòng đăng ký, không sửa file chứa logic cũ. Code đang chạy đúng không bị động tới.
Vì sao phương thức chọn khuyến mãi tốt nhất dễ hơn sau khi refactor?
Vì mọi chiến lược đều cùng một interface nên duyệt qua chúng là một chuỗi LINQ: Where lọc chiến lược áp dụng được, Select tính số tiền, MaxBy lấy cái lớn nhất. Với switch cũ, phải lặp qua mọi giá trị enum và gọi lại hàm nhiều lần. Đây là khả năng mới xuất hiện nhờ cấu trúc chứ không nhờ viết thêm logic.
Khi nào từ điển Func đủ dùng, không cần lớp chiến lược?
Khi mỗi quy tắc chỉ là một công thức ngắn và không cần dependency riêng. Thêm quy tắc là thêm một dòng vào từ điển. Khi một quy tắc cần dependency như kho voucher hay bảng hạng khách hàng, hoặc dài hơn vài dòng, thì tách riêng nó thành lớp chiến lược.
Hai câu hỏi phân biệt SOLID đúng chỗ và lạm dụng là gì?
Phần này có thật sự thay đổi thường xuyên không, và abstraction này có ít nhất hai cài đặt thật không. Cả hai đều cần bằng chứng chứ không phải phỏng đoán về tương lai.
Interface chỉ có một cài đặt có bao giờ hợp lệ không?
Có, khi nó che một thứ nằm ngoài tiến trình (đĩa, mạng, đồng hồ) và bạn cần thay nó bằng bản giả trong test, ví dụ IEmailSender. Bản giả trong test chính là cài đặt thứ hai. Ngoài trường hợp đó, nó chỉ thêm một lớp gián tiếp.
Kết luận
Ba điều đáng nhớ nhất:
- Mỗi thay đổi chạm vào code đang chạy đúng là dấu hiệu rõ nhất của vi phạm mở/đóng.
- Cần bằng chứng trước khi tách, không phải phỏng đoán về tương lai. Có mức trung gian (từ điển
Func) trước khi tới lớp chiến lược. - Interface một cài đặt và không dùng cho test là chi phí thuần.
Tham khảo
- Object-oriented programming in C#
- Dependency injection in .NET
- Architectural principles
- TimeProvider — lớp thời gian thay được trong test
Điều hướng
- Bài trước: 5.9 — Đọc thêm: IAsyncEnumerable, source generator và Span
- Bài tiếp theo: 5.11 — Sự cố thực tế: rò rỉ bộ nhớ do event
- Về module: Trang mục lục