Skip to content

5.10 - Case study: refactor hàm tính giảm giá ​

Tóm tắt: Một switch 8 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ó, record cho kết quả, và Func cho 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òng switch là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 switch lớ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à record trong một thiết kế hoàn chỉnh.
  • Chọn giữa switch, từ điển Func và 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:

csharp
// 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:

csharp
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:

text
- 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ợp

Vấ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:

csharp
public interface IDiscountStrategy
{
    PromotionType Type { get; }
    bool AppliesTo(Order order);
    decimal CalculateDiscount(Order order);
}
csharp
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 ​

csharp
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:

csharp
// 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).

csharp
// 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:

text
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à đủ:

csharp
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.

switchTừ điển FuncLớp chiến lược
Chi phí viếtThấp nhấtThấpMột lớp mỗi loại
Mỗi loại có dependency riêngLớp chứa gánh hếtKhóDễ
Test riêng từng loạiKhóĐượcDễ
Hợp khiSố loại cố định, logic ngắnLogic ngắn, hay thêm loạiLogic dài, dependency khác nhau

5.10.5 — Kết quả ​

Chỉ sốTrướcSau
Dòng trong một file18710–40 mỗi chiến lược
Dependency của lớp điều phối71 (danh sách chiến lược)
Thêm khuyến mãi mớiSửa file đang chạy đúngThêm file mới
Test cho một chiến lượcPhải dựng 7 dependencyChỉ dữ liệu của chính nó
Chọn khuyến mãi tốt nhấtKhông làm đượcMộ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:

csharp
// 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.

csharp
// 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:

csharp
// 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);
}
csharp
// 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:

text
Voucher      : 100.000
text
File 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 xanh

PromotionType.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:

text
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ợp
text
File 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ượcswitch
File mới20
File hiện có phải sửa14–5
Code đang chạy đúng bị chạm tớiKhôngCó
Dependency của lớp lớn nhấtkhông đổi7 → 9
Phải chạy lại test của khuyến mãi cũKhôngCó

Và đây là điều nguyên tắc mở/đóng thật sự hứa hẹn:

text
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ó
text
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.

text
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:

csharp
// 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õ",
};
text
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.

csharp
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.

text
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)
text
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.

text
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 file

Tách theo đúng các tập con đó:

csharp
// 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êng
text
Trước: 1 lớp, 9 dependency
Sau  : 3 lớp, 3–5 dependency mỗi lớp

Và đây là câu hỏi quyết định, thay cho mọi ngưỡng cứng:

text
"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 dependency
text
Ví 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ại

Vì sao ngưỡng cứng ("quá 5 dependency là xấu") không dùng được:

csharp
// 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);
text
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 khiNhưng kiểm tra thêm
Số dependency> 6Mỗi phương thức dùng bao nhiêu?
Số dòng> 300Có nhóm phương thức rời nhau không?
Số phương thức công khai> 8Chúng có chung dữ liệu không?
text
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ách

Visual 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:

text
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 Excel

Vớ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:

text
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
ICustomerMapperThừaÁnh xạ thuần, không I/O, test được trực tiếp
ILeadValidatorThừaHàm thuần, gọi thẳng là được
IOrderCalculatorThừaTính toán thuần
IEmailTemplateEngineGiữĐọc template từ đĩa — có I/O
IReportExporterGiữ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:

text
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
csharp
// 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);
}
text
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:

text
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:

csharp
public interface IClock { DateTimeOffset Now { get; } }
text
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.
csharp
// .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:

text
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:

  1. 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.
  2. 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.
  3. Interface một cài đặt và không dùng cho test là chi phí thuần.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.