4.11 - Case study: mô hình hoá sản phẩm bằng composition
Tóm tắt: Bài toán thiết kế cổ điển và đầy cạm bẫy: mô hình hoá sản phẩm trong một CRM bán cả hàng vật lý, dịch vụ và gói đăng ký. Phản xạ đầu tiên của hầu hết mọi người là dùng kế thừa — và nó hoạt động hoàn hảo trong hai tháng, rồi sụp đổ khi có yêu cầu "một sản phẩm vừa là hàng vật lý vừa bán theo gói". Lúc đó cây kế thừa không mở rộng được nữa mà không viết lại từ đầu. Bài này đi qua cả hai thiết kế, cho thấy chính xác chỗ kế thừa gãy, và trình bày cách dùng composition để cùng một yêu cầu chỉ mất vài dòng. Nguyên tắc rút ra: kế thừa mô hình quan hệ "là một" cố định; composition mô hình khả năng có thể thêm bớt.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nhận ra khi nào kế thừa là lựa chọn sai.
- Thiết kế bằng composition thay cho cây kế thừa.
- Dùng
recordcho value object đúng chỗ. - Đánh giá một thiết kế bằng câu hỏi "yêu cầu tiếp theo sẽ thế nào".
Cần biết trước: kế thừa và composition ở bài 4.3, record và value object ở bài 4.7, "trạng thái không phải kiểu" ở mục 4.9.3. Code của thiết kế 2 chạy được trọn vẹn trong một project console.
Nội dung bài học
4.11.1 — Đề bài
CRM bán ba loại sản phẩm:
- Hàng vật lý: có cân nặng, kích thước, tồn kho, phí vận chuyển
- Dịch vụ: có thời lượng, người thực hiện, không có tồn kho
- Gói đăng ký: có chu kỳ thanh toán, tự gia hạn
Yêu cầu chung: mã sản phẩm, tên, giá, thuế suất, trạng thái kinh doanhCả hai thiết kế dùng chung value object Money (số tiền kèm đơn vị tiền tệ, như bài 4.7), có thêm hai toán tử + và * để cộng tiền và nhân với số lượng:
public sealed record Money(decimal Amount, string Currency)
{
public static Money Vnd(decimal amount) => new(amount, "VND");
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException("Không cộng được hai loại tiền khác nhau.");
return a with { Amount = a.Amount + b.Amount };
}
public static Money operator *(Money a, int quantity) => a with { Amount = a.Amount * quantity };
public override string ToString() => $"{Amount:N0} {Currency}";
}
public enum BillingCycle { Monthly, Yearly }public static Money operator +(...) là cách định nghĩa toán tử cho kiểu của bạn: sau đó viết được price + shipping thay vì price.Add(shipping).
4.11.2 — Thiết kế 1: kế thừa
public abstract class Product
{
public Guid Id { get; } = Guid.NewGuid();
public string Name { get; }
public Money Price { get; }
public bool IsActive { get; protected set; } = true;
protected Product(string name, Money price)
{
Name = name;
Price = price;
}
public abstract Money CalculateTotal(int quantity);
}
public class PhysicalProduct : Product
{
public decimal WeightKg { get; }
public int StockQuantity { get; private set; }
public PhysicalProduct(string name, Money price, decimal weightKg, int stockQuantity)
: base(name, price)
{
WeightKg = weightKg;
StockQuantity = stockQuantity;
}
public override Money CalculateTotal(int quantity)
=> Price * quantity + CalculateShipping(quantity);
private Money CalculateShipping(int quantity) => Money.Vnd(WeightKg * quantity * 15_000m);
}
public class ServiceProduct : Product
{
public TimeSpan Duration { get; }
public ServiceProduct(string name, Money price, TimeSpan duration) : base(name, price)
=> Duration = duration;
public override Money CalculateTotal(int quantity) => Price * quantity;
}
public class SubscriptionProduct : Product
{
public BillingCycle Cycle { get; }
public bool AutoRenew { get; }
public SubscriptionProduct(string name, Money price, BillingCycle cycle, bool autoRenew)
: base(name, price)
{
Cycle = cycle;
AutoRenew = autoRenew;
}
public override Money CalculateTotal(int quantity) => Price * quantity;
}Thiết kế này tốt — trong hai tháng.
4.11.3 — Chỗ nó gãy
Yêu cầu tháng thứ ba: "Bán máy in kèm gói mực hàng tháng. Nó là hàng vật lý (giao hàng, tồn kho) và cũng là gói đăng ký (thu tiền hàng tháng)."
// Không hợp lệ trong C# — không thể kế thừa HAI lớp
public class PhysicalSubscriptionProduct : PhysicalProduct, SubscriptionProduct { }error CS1721: Class 'PhysicalSubscriptionProduct' cannot have multiple base classes: 'PhysicalProduct' and 'SubscriptionProduct'Ba cách chữa, cả ba đều tệ:
| Cách chữa | Vấn đề |
|---|---|
Chép thuộc tính đăng ký vào PhysicalProduct | Dịch vụ cũng cần? Lại chép lần nữa |
Đưa mọi thuộc tính lên lớp Product | Lớp cơ sở phình to, hàng vật lý có AutoRenew vô nghĩa |
Thêm PhysicalSubscriptionProduct : PhysicalProduct rồi chép phần đăng ký vào | Sửa một chỗ phải nhớ sửa hai |
Và yêu cầu tháng thứ tư: "Dịch vụ tư vấn cũng bán theo gói." Giờ cần ServiceSubscriptionProduct. Tháng thứ năm: "Gói bảo trì gồm cả linh kiện."
Số lớp tăng theo tổ hợp, không phải theo số loại. Đây chính là điểm gãy của kế thừa cho bài toán này.
4.11.4 — Thiết kế 2: composition
Câu hỏi cần hỏi lại từ đầu: "hàng vật lý" là bản chất của sản phẩm, hay là một khả năng nó có?
Nhìn lại yêu cầu: một sản phẩm có thể cần giao hàng, có thể thu tiền định kỳ, có thể cần người thực hiện. Đó là khả năng, không phải bản chất — nên nó thuộc về composition.
public sealed class Product
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public Money Price { get; private set; }
public decimal TaxRate { get; private set; }
public bool IsActive { get; private set; }
// Khả năng: có thể có, có thể không
public ShippingInfo? Shipping { get; private set; }
public SubscriptionInfo? Subscription { get; private set; }
public ServiceInfo? Service { get; private set; }
private Product(string name, Money price)
{
Id = Guid.NewGuid();
Name = name;
Price = price;
IsActive = true;
}
public static Product Create(string name, Money price)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Tên sản phẩm không được rỗng.", nameof(name));
if (price.Amount < 0)
throw new ArgumentOutOfRangeException(nameof(price), "Giá không được âm.");
return new Product(name, price);
}
public Money CalculateTotal(int quantity)
{
var total = Price * quantity;
if (Shipping is not null)
total += Shipping.CalculateCost(quantity);
return total;
}
public void EnableShipping(decimal weightKg, Dimensions size)
=> Shipping = new ShippingInfo(weightKg, size);
public void EnableSubscription(BillingCycle cycle, bool autoRenew)
=> Subscription = new SubscriptionInfo(cycle, autoRenew);
public void EnableService(TimeSpan duration, int consultantId)
=> Service = new ServiceInfo(duration, consultantId);
public void DisableSubscription() => Subscription = null;
}
// Value object — bất biến, so sánh theo giá trị
public sealed record Dimensions(int LengthCm, int WidthCm, int HeightCm);
public sealed record ShippingInfo(decimal WeightKg, Dimensions Size)
{
public Money CalculateCost(int quantity) => Money.Vnd(WeightKg * quantity * 15_000m);
}
public sealed record SubscriptionInfo(BillingCycle Cycle, bool AutoRenew);
public sealed record ServiceInfo(TimeSpan Duration, int ConsultantId);Product.Create là factory method (mục 4.1.6): constructor để private, mọi sản phẩm phải ra đời qua Create, nơi kiểm tra tên và giá.
Máy in kèm gói mực, và dịch vụ tư vấn bán theo gói, giờ là:
var printer = Product.Create("Máy in HP", Money.Vnd(5_000_000m));
printer.EnableShipping(weightKg: 8.5m, size: new Dimensions(45, 35, 30));
printer.EnableSubscription(BillingCycle.Monthly, autoRenew: true);
var consulting = Product.Create("Tư vấn CRM", Money.Vnd(2_000_000m));
consulting.EnableService(TimeSpan.FromHours(2), consultantId: 7);
consulting.EnableSubscription(BillingCycle.Monthly, autoRenew: false);
var projector = Product.Create("Máy chiếu", Money.Vnd(12_000_000m));
projector.EnableShipping(weightKg: 3.2m, size: new Dimensions(40, 30, 15));
Product[] products = [printer, consulting, projector];
foreach (var p in products)
Console.WriteLine($"{p.Name,-12} x2 = {p.CalculateTotal(2)}");Đặt đoạn này ở đầu Program.cs, các khai báo Money, BillingCycle, Product và các record ở phía sau, rồi dotnet run. Kết quả chạy thật trên .NET 10 (máy đặt định dạng số Việt Nam nên phân cách hàng nghìn là dấu chấm):
Máy in HP x2 = 10.255.000 VND
Tư vấn CRM x2 = 4.000.000 VND
Máy chiếu x2 = 24.096.000 VNDMáy in: 2 × 5.000.000 cộng phí giao 8,5 kg × 2 × 15.000 = 255.000. Không lớp mới nào cả. Ba khả năng bật tắt độc lập cho ra tám tổ hợp, và tám tổ hợp chỉ cần một lớp.
4.11.5 — Vì sao dùng record cho khả năng
public sealed record ShippingInfo(decimal WeightKg, Dimensions Size);Ba lý do cụ thể:
Bất biến. Sau khi tạo, không ai đổi được WeightKg từ bên ngoài. Muốn đổi thì tạo bản mới qua EnableShipping — nên Product luôn kiểm soát được trạng thái của mình.
So sánh theo giá trị. Hai ShippingInfo cùng số liệu là bằng nhau, điều này đúng về mặt nghĩa: chúng mô tả cùng một cấu hình vận chuyển, không phải hai thực thể khác nhau.
Ngắn gọn. Một dòng thay cho constructor, property, Equals, GetHashCode và ToString viết tay (bài 4.7).
4.11.6 — Khi nào kế thừa vẫn đúng
Composition không phải lúc nào cũng thắng. Kế thừa phù hợp khi quan hệ "là một" cố định và không chồng chéo:
// ĐÚNG — một exception KHÔNG THỂ vừa là NotFound vừa là Validation
public abstract class DomainException(string message) : Exception(message);
public sealed class NotFoundException(string message) : DomainException(message);
public sealed class ValidationException(string message) : DomainException(message);Hoặc khi lớp cơ sở do framework cung cấp hành vi mặc định mà lớp con chỉ tinh chỉnh. Ví dụ trong ASP.NET Core (bài 8.4), public class LeadsController : ControllerBase kế thừa toàn bộ hạ tầng xử lý request, bạn chỉ thêm action.
Câu hỏi phân biệt: "một đối tượng có thể thuộc hai loại con cùng lúc không?"
- Không bao giờ → kế thừa được.
- Có thể → composition.
Trong ca này, một sản phẩm có thể vừa cần giao hàng vừa thu định kỳ, nên câu trả lời là composition (bài 4.3).
4.11.7 — Đánh giá thiết kế bằng yêu cầu tiếp theo
Cách kiểm tra rẻ nhất trước khi cam kết một thiết kế: nghĩ ra ba yêu cầu có thể tới trong sáu tháng và xem thiết kế phản ứng thế nào.
| Yêu cầu | Kế thừa | Composition |
|---|---|---|
| Sản phẩm vừa vật lý vừa đăng ký | Không làm được | Gọi thêm một phương thức |
| Thêm khả năng "cho thuê" | Thêm nhiều lớp tổ hợp | Thêm 1 record |
| Báo cáo mọi sản phẩm cần giao hàng | Kiểm tra kiểu ở nhiều nơi | Lọc Shipping is not null |
| Bỏ khả năng đăng ký của một sản phẩm | Đổi kiểu đối tượng — rất khó | DisableSubscription() |
Hàng cuối đáng chú ý: với kế thừa, "sản phẩm này không bán theo gói nữa" nghĩa là đổi kiểu của đối tượng — điều gần như không làm được khi nó đã có id và đang được tham chiếu ở nơi khác.
4.11.8 — Rà lại code của bạn
Danh sách rà soát thiết kế lớp
- Đã hỏi một đối tượng có thể thuộc hai loại con cùng lúc không.
- Khả năng có thể thêm bớt được mô hình bằng composition.
- Kế thừa chỉ dùng cho quan hệ là một cố định và không chồng chéo.
- Value object dùng record, bất biến và so sánh theo giá trị.
- Lớp cơ sở không chứa thuộc tính chỉ đúng với một số lớp con.
- Đã thử ba yêu cầu giả định trong sáu tháng trước khi chốt thiết kế.
- Không có lớp nào tên ghép từ hai loại để xử lý tổ hợp.
Bài tập áp dụng
Bài 1 — Thêm khả năng mới
Thêm khả năng "cho thuê" (có thời hạn thuê và tiền cọc) vào thiết kế composition, rồi tạo một máy chiếu vừa giao hàng vừa cho thuê 7 ngày, cọc 2.000.000. Sau đó đếm: nếu dùng thiết kế kế thừa, cần thêm bao nhiêu lớp để phủ mọi tổ hợp?
Tiêu chí hoàn thành: code composition chạy và in đúng thời hạn, tiền cọc; bạn có con số cụ thể cho cả hai thiết kế, và nhận ra chênh lệch không nằm ở lần thêm này mà ở lần thêm tiếp theo.
Gợi ý và lời giải — Bài 1
Gợi ý. Với thiết kế kế thừa, đừng chỉ đếm lớp mới. Đếm cả những lớp tổ hợp mà nó kéo theo.
Lời giải — thiết kế composition:
// 1. Thêm một record
public sealed record RentalInfo(TimeSpan Duration, Money Deposit);
// 2. Thêm một thuộc tính và một phương thức vào Product
public RentalInfo? Rental { get; private set; }
public void EnableRental(TimeSpan duration, Money deposit)
=> Rental = new RentalInfo(duration, deposit);Sửa 1 file (Product), thêm 1 record, khoảng 6 dòng. Mọi tổ hợp dùng được ngay:
var projector = Product.Create("Máy chiếu", Money.Vnd(12_000_000m));
projector.EnableShipping(weightKg: 3.2m, size: new Dimensions(40, 30, 15));
projector.EnableRental(TimeSpan.FromDays(7), Money.Vnd(2_000_000m));
Console.WriteLine($"{projector.Name}: thuê {projector.Rental!.Duration.TotalDays} ngày, cọc {projector.Rental.Deposit}");Máy chiếu: thuê 7 ngày, cọc 2.000.000 VNDThiết kế kế thừa — đếm cho đủ. Mỗi khả năng (giao hàng, đăng ký, dịch vụ) hoặc có hoặc không, nên ba khả năng cho 2³ = 8 tổ hợp, trong đó một tổ hợp "không có gì" là lớp Product gốc. Để phủ mọi tổ hợp cần tới 7 lớp con. Thêm "cho thuê":
3 khả năng -> 2³ = 8 tổ hợp
4 khả năng -> 2⁴ = 16 tổ hợp -> thêm 8 lớp tổ hợp mới, ví dụ:
RentalProduct, ShippableRentalProduct, SubscriptionRentalProduct,
ShippableSubscriptionRentalProduct, ServiceRentalProduct, ...Cộng thêm: mọi chỗ trong code đang switch hay is trên kiểu sản phẩm đều phải thêm nhánh cho từng lớp mới.
Bảng so sánh:
| Composition | Kế thừa | |
|---|---|---|
| Lớp/record mới | 1 | 8 |
| File hiện có phải sửa | 1 | Mọi chỗ switch/is trên kiểu |
Rủi ro sót một chỗ switch | Không có | Cao |
Nhưng con số quan trọng hơn là ở lần thêm TIẾP THEO:
Khả năng thứ 5 với composition : vẫn 1 record + 1 thuộc tính
Khả năng thứ 5 với kế thừa : 2⁵ = 32 tổ hợp
Composition: chi phí HẰNG SỐ
Kế thừa: chi phí NHÂN ĐÔI mỗi lầnĐây chính là điều mục 4.11.7 nói — đánh giá một thiết kế bằng yêu cầu tiếp theo, không bằng yêu cầu hiện tại.
Và một chi phí của kế thừa mà việc đếm file không thấy: một sản phẩm đang là PhysicalProduct, khách hàng muốn thêm gói dịch vụ. Phải đổi kiểu của đối tượng — nhưng C# không đổi được kiểu của một đối tượng đã tồn tại. Phải tạo đối tượng mới, chép mọi thuộc tính, cập nhật mọi tham chiếu, và nếu đã lưu database thì đổi cả dòng dữ liệu. Với composition, cùng việc đó là một dòng product.EnableSubscription(BillingCycle.Monthly, autoRenew: true);.
Khi nào kế thừa vẫn đúng (mục 4.11.6): khi các nhánh loại trừ lẫn nhau và không đổi qua lại. Một phương thức thanh toán là thẻ, chuyển khoản, hoặc tiền mặt — không bao giờ là hai thứ cùng lúc, và một giao dịch không đổi phương thức giữa chừng.
Bài 2 — Nhận diện lớp tổ hợp
Một dự án CRM có các lớp sau (ví dụ minh hoạ). Với từng lớp, đánh giá: tên ghép này là triệu chứng của kế thừa sai chỗ, hay là tên ghép hợp lý?
ShippableSubscriptionProduct : Product (có 6 lớp anh em kiểu tương tự)
DigitalSubscriptionProduct : Product
AdminManagerUser : AdminUser
EmailSmsNotification : Notification
ReadOnlyCachedRepository : IRepository (bọc một IRepository khác)
LeadWithCustomerAndOrdersDto (dữ liệu trả về của một API)Tiêu chí hoàn thành: bạn có đánh giá cho từng lớp kèm lý do, và phân biệt được tên ghép hợp lý với tên ghép là triệu chứng.
Gợi ý và lời giải — Bài 2
Gợi ý. Không phải mọi tên ghép đều xấu. AsyncLocal và ReadOnlyDictionary trong thư viện chuẩn là tên ghép hoàn toàn hợp lý. Dùng ba câu hỏi:
1. Hai khái niệm trong tên có LOẠI TRỪ nhau không?
Không loại trừ -> nhiều khả năng là triệu chứng
2. Có bao nhiêu lớp anh em cùng kiểu ghép?
Nhiều -> chắc chắn là triệu chứng
3. Một đối tượng có bao giờ cần CHUYỂN từ dạng này sang dạng khác không?
Có -> kế thừa sai, vì không đổi kiểu đượcLời giải:
| Lớp | Đánh giá | Lý do |
|---|---|---|
ShippableSubscriptionProduct | Triệu chứng | Hai khả năng độc lập, có 6 lớp anh em |
DigitalSubscriptionProduct | Triệu chứng | Cùng nhóm trên |
AdminManagerUser | Triệu chứng | Vai trò nên là dữ liệu, không phải kiểu |
EmailSmsNotification | Triệu chứng | Kênh gửi là danh sách, không phải phân cấp |
ReadOnlyCachedRepository | Hợp lý | Hai decorator chồng lên nhau, đúng mẫu |
LeadWithCustomerAndOrdersDto | Hợp lý | DTO mô tả đúng hình dạng dữ liệu trả về |
1. ReadOnlyCachedRepository — decorator chồng nhau là mẫu đúng. Decorator là lớp cài cùng interface với lớp nó bọc, thêm một hành vi rồi chuyển phần còn lại cho lớp bên trong:
IRepository inner = new PostgresRepository(connectionString);
IRepository cached = new CachedRepository(inner, cache);
IRepository safe = new ReadOnlyRepository(cached);Tổ hợp xảy ra lúc chạy, không phải lúc biên dịch: 3 decorator cho 8 tổ hợp mà không cần 8 lớp. Đây là composition, chỉ là tên lớp nghe giống tổ hợp.
2. LeadWithCustomerAndOrdersDto — DTO mô tả hình dạng. Đây không phải mô hình nghiệp vụ, nó là hình dạng của một phản hồi API. Tên dài nhưng nói đúng nó chứa gì, nên hữu ích. Nếu có 15 DTO kiểu này cho cùng một entity, lúc đó mới là vấn đề khác.
Còn AdminManagerUser — vì sao vai trò không nên là kiểu:
// SAI — vai trò thành phân cấp kiểu
class User { }
class ManagerUser : User { }
class AdminUser : User { }
class AdminManagerUser : AdminUser { } // và khi có vai trò thứ tư?// ĐÚNG — vai trò là dữ liệu
class User
{
public HashSet<Role> Roles { get; } = [];
public bool HasPermission(Permission p) => Roles.Any(r => r.Permissions.Contains(p));
}Lý do quyết định: một người dùng được thăng chức. Với kiểu: phải tạo đối tượng mới, đổi mọi tham chiếu, đổi dòng trong database. Với dữ liệu: user.Roles.Add(Role.Manager);. Đây đúng là quy tắc "trạng thái không phải kiểu" của mục 4.9.3.
Một dấu hiệu khác: số chỗ switch trên kiểu. Trong dự án thật, đếm số chỗ code viết is ...Product hay switch trên kiểu sản phẩm (Ctrl+Shift+F trong IDE). Mỗi lớp mới đồng nghĩa với ngần ấy chỗ phải xem lại, và trình biên dịch không nhắc bạn: như bài tập 1 của bài 4.8 đã chứng minh, C# không kiểm tra vét cạn trên một phân cấp kiểu, kể cả khi mọi lớp con đều sealed. Con số này thường thuyết phục hơn cả số lớp: nó đo chi phí thay đổi, không đo kích thước hiện tại.
Bài 3 — Viết truy vấn
Với thiết kế composition, viết truy vấn LINQ lấy mọi sản phẩm cần giao hàng, và mọi sản phẩm "cần giao hàng hoặc bán theo gói". Viết hai truy vấn tương ứng cho thiết kế kế thừa (giả sử đã có đủ các lớp tổ hợp) và so sánh.
Tiêu chí hoàn thành: bạn viết được truy vấn cho cả hai thiết kế, chạy được bản composition trên danh sách products của mục 4.11.4, và giải thích vì sao bản kế thừa dễ sót.
Gợi ý và lời giải — Bài 3
Gợi ý. Câu hỏi quyết định không phải "code nào ngắn hơn" mà "khi thêm một lớp hay một khả năng mới, truy vấn nào phải sửa".
Lời giải — với composition:
var needShipping = products.Where(p => p.Shipping is not null).Select(p => p.Name);
Console.WriteLine("Cần giao hàng: " + string.Join(", ", needShipping));
var shippingOrSubscription = products
.Where(p => p.Shipping is not null || p.Subscription is not null)
.Select(p => p.Name);
Console.WriteLine("Giao hàng hoặc theo gói: " + string.Join(", ", shippingOrSubscription));Cần giao hàng: Máy in HP, Máy chiếu
Giao hàng hoặc theo gói: Máy in HP, Tư vấn CRM, Máy chiếuĐiều kiện phản ánh đúng câu hỏi nghiệp vụ. Thêm khả năng "cho thuê" không làm truy vấn nào phải sửa.
Với kế thừa — phải tự liệt kê mọi lớp có khả năng giao hàng:
var needShipping = products.Where(p => p is PhysicalProduct
|| p is PhysicalSubscriptionProduct
|| p is PhysicalServiceProduct
|| p is PhysicalSubscriptionServiceProduct);
// "giao hàng HOẶC theo gói": phải liệt kê 6 trong 8 tổ hợp
var shippingOrSubscription = products.Where(p => p is PhysicalProduct || p is SubscriptionProduct
|| p is PhysicalSubscriptionProduct || p is PhysicalServiceProduct
|| p is SubscriptionServiceProduct
|| p is PhysicalSubscriptionServiceProduct);Thêm một lớp có khả năng giao hàng là phải sửa mọi truy vấn kiểu này, và quên một chỗ thì không có lỗi nào, chỉ thiếu dữ liệu. Người viết phải tự tính ra tập con của phân cấp, nên dễ sót và khó đọc lại.
Xem trước — Module 13: hệ quả ở tầng database. Khi lưu sản phẩm vào PostgreSQL bằng EF Core (bài 8.10 giới thiệu, Module 13 dạy kỹ), khác biệt giữa hai thiết kế càng rõ. Phần này chỉ cần đọc hiểu.
Với composition, mỗi khả năng thành một nhóm cột nằm ngay trong bảng Products (EF Core gọi là owned type). Cấu hình, kèm một index chỉ chứa những dòng có giao hàng:
modelBuilder.Entity<Product>(builder =>
{
builder.OwnsOne(p => p.Price);
builder.OwnsOne(p => p.Shipping, s =>
{
s.Property(x => x.WeightKg).HasColumnName("Shipping_WeightKg");
s.OwnsOne(x => x.Size);
s.HasIndex(x => x.WeightKg) // index khai báo TRONG OwnsOne
.HasFilter("\"Shipping_WeightKg\" IS NOT NULL");
});
builder.OwnsOne(p => p.Subscription);
builder.OwnsOne(p => p.Service);
});Index của thuộc tính trong owned type phải khai báo bên trong OwnsOne, không khai báo được từ builder của Product bằng p => p.Shipping!.WeightKg. Để EF Core dựng lại được ShippingInfo (vì nó chứa owned type lồng Size), record cần thêm một constructor không tham số private ShippingInfo() : this(0m, null!) { }, và Product cần private Product() { Name = null!; Price = null!; }.
Truy vấn products.Where(p => p.Shipping != null).Select(p => new { p.Id, p.Name, p.Shipping!.WeightKg }) được EF Core 10 dịch sang SQL như sau (chạy thật trên PostgreSQL 16):
SELECT p."Id", p."Name", p."Shipping_WeightKg" AS "WeightKg"
FROM "Products" AS p
WHERE p."Shipping_WeightKg" IS NOT NULLVà index được tạo:
CREATE INDEX "IX_Products_Shipping_WeightKg" ON "Products" ("Shipping_WeightKg") WHERE "Shipping_WeightKg" IS NOT NULL;Một truy vấn, lọc ở database, chỉ lấy ba cột, và index có lọc chỉ chứa những dòng có khả năng giao hàng nên nhỏ hơn nhiều so với index trên toàn bảng.
Với kế thừa, cách ánh xạ mặc định của EF Core là một bảng cho cả cây (table-per-hierarchy): mọi lớp con nằm chung bảng, thêm cột Discriminator ghi tên lớp. Truy vấn p is ShippableProduct || p is ShippableSubscriptionProduct thành:
SELECT p."Id", p."Discriminator", p."Name"
FROM "Products" AS p
WHERE p."Discriminator" IN ('ShippableProduct', 'ShippableSubscriptionProduct')Dịch được, nhưng danh sách trong IN (...) chính là danh sách lớp bạn tự liệt kê, nên thêm lớp mà quên sửa truy vấn thì database trả thiếu dữ liệu mà không báo gì. Cách ánh xạ khác (mỗi lớp một bảng) thì mỗi truy vấn thành một chuỗi LEFT JOIN, càng nhiều lớp càng nhiều JOIN.
SQL Server
Cùng cấu hình, chỉ khác cú pháp bộ lọc của index: SQL Server đặt tên cột trong ngoặc vuông.
s.HasIndex(x => x.WeightKg)
.HasFilter("[Shipping_WeightKg] IS NOT NULL");SQL do EF Core 10 sinh ra, chạy thật trên SQL Server 2022:
SELECT [p].[Id], [p].[Name], [p].[Shipping_WeightKg] AS [WeightKg]
FROM [Products] AS [p]
WHERE [p].[Shipping_WeightKg] IS NOT NULLCREATE INDEX [IX_Products_Shipping_WeightKg] ON [Products] ([Shipping_WeightKg]) WHERE [Shipping_WeightKg] IS NOT NULL;SELECT [p].[Id], [p].[Discriminator], [p].[Name]
FROM [Products] AS [p]
WHERE [p].[Discriminator] IN (N'ShippableProduct', N'ShippableSubscriptionProduct')Và cách nhiều người chọn khi hai cách trên khó: nạp hết rồi lọc trong bộ nhớ. Viết (await db.Products.ToListAsync()).Where(p => p is PhysicalProduct) nghĩa là kéo toàn bộ bảng sản phẩm về ứng dụng chỉ để lấy một phần nhỏ — đúng loại lỗi ở bài 1.10, chỉ xuất hiện dưới một hình thức khác.
Nối lại ba bài: bài 1 đo chi phí thêm khả năng, bài 2 tìm triệu chứng trong code, bài 3 cho thấy hệ quả ở tầng truy vấn. Điểm chung của cả ba: kế thừa cố định tổ hợp lúc biên dịch, còn composition để nó là dữ liệu lúc chạy — và mọi khác biệt còn lại đều bắt nguồn từ đó.
Tự kiểm tra
Vì sao cây kế thừa gãy khi có yêu cầu kết hợp hai loại?
Vì C# không cho kế thừa nhiều lớp (lỗi CS1721). Ba cách chữa đều tệ: chép thuộc tính sang lớp khác, đưa mọi thứ lên lớp cơ sở khiến nó phình to, hoặc tạo lớp tổ hợp chép code. Số lớp tăng theo tổ hợp chứ không theo số loại.
Câu hỏi phân biệt kế thừa và composition là gì?
Một đối tượng có thể thuộc hai loại con cùng lúc không. Nếu không bao giờ thì kế thừa được, ví dụ một exception không thể vừa là NotFound vừa là Validation. Nếu có thể thì dùng composition.
Vì sao dùng record cho các khả năng?
Vì chúng bất biến nên không ai đổi được từ bên ngoài, so sánh theo giá trị đúng với nghĩa của chúng, và chỉ cần một dòng thay cho constructor, property, Equals, GetHashCode và ToString viết tay.
Kế thừa vẫn đúng trong trường hợp nào?
Khi quan hệ là một cố định và không chồng chéo, như cây exception, hoặc khi lớp cơ sở do framework cung cấp hành vi hạ tầng mà lớp con chỉ tinh chỉnh, như ControllerBase trong ASP.NET Core.
Vì sao bỏ một khả năng lại khó với kế thừa?
Vì nó nghĩa là đổi kiểu của đối tượng, điều gần như không làm được khi đối tượng đã có id và đang được tham chiếu ở nơi khác. Với composition thì chỉ cần gán null cho khả năng đó, qua một phương thức như DisableSubscription.
Cách kiểm tra một thiết kế trước khi cam kết là gì?
Nghĩ ra ba yêu cầu có thể tới trong sáu tháng và xem thiết kế phản ứng thế nào. Thiết kế nào khiến một yêu cầu hợp lý trở thành không làm được thì nên xem lại ngay.
Kết luận
Ba điều đáng nhớ nhất:
- Kế thừa mô hình bản chất cố định; composition mô hình khả năng thêm bớt được.
- Số lớp tăng theo tổ hợp là dấu hiệu kế thừa sai chỗ.
- Thử thiết kế bằng ba yêu cầu giả định trước khi cam kết — rẻ hơn viết lại rất nhiều.
Tham khảo
- Lập trình hướng đối tượng trong C#
- Kế thừa trong C#
- Kiểu record
- Operator overloading
- EF Core — Owned entity types
Điều hướng
- Bài trước: 4.10 — C# hiện đại cho class: required, primary constructor, IDisposable
- Bài tiếp theo: 4.12 — Bug so sánh đối tượng: Equals, GetHashCode và record
- Về module: Trang mục lục