4.4 - Interface
Tóm tắt: Interface là hợp đồng: nó nói làm được gì mà không nói làm bằng cách nào. Nhưng giá trị thật không nằm ở việc "có thể có nhiều cài đặt" — phần lớn interface trong dự án thật chỉ có đúng một cài đặt. Giá trị nằm ở chỗ nó đảo ngược hướng phụ thuộc: tầng nghiệp vụ định nghĩa thứ nó cần, tầng hạ tầng đi theo, và nhờ vậy nghiệp vụ không còn phụ thuộc vào database hay nhà cung cấp email. Bài này cũng nói thẳng về mặt kia: không phải class nào cũng cần interface, và một
IFooServicechỉ để bọcFooServicelà nghi lễ chứ không phải thiết kế.
Mục tiêu bài học
Sau bài này bạn có thể:
- Viết interface mô tả năng lực thay vì mô tả class hiện có.
- Giải thích đảo ngược phụ thuộc và vẽ được hướng mũi tên trước/sau.
- Áp dụng tiêu chí chia nhỏ interface thay vì gom thành một khối lớn.
- Quyết định khi nào không cần interface.
- Dùng cài đặt tường minh và default interface method cho đúng chỗ.
Cần biết trước: bài 4.3 (kế thừa, composition). Bài tập 3 viết test xUnit như bài 0.4.
Nhiều phương thức trong bài trả về Task hoặc Task<T> và có tham số CancellationToken ct. Đó là dấu hiệu của phương thức bất đồng bộ: Task<Order?> nghĩa là "một Order? sẽ có khi thao tác (gọi database, gọi mạng) xong", còn CancellationToken cho phép huỷ thao tác giữa chừng. Module 6 dạy kỹ; trong bài này chỉ cần đọc chúng như kiểu trả về bình thường.
Nội dung bài học
4.4.1 — Hợp đồng, không phải cài đặt
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string token, CancellationToken ct);
Task RefundAsync(string transactionId, CancellationToken ct);
}
public class StripeGateway : IPaymentGateway { /* gọi Stripe */ }
public class VnPayGateway : IPaymentGateway { /* gọi VNPay */ }
public class FakeGateway : IPaymentGateway { /* dùng trong test */ }Code nghiệp vụ chỉ biết IPaymentGateway. Đổi nhà cung cấp là đổi một dòng đăng ký, không đụng tới logic đặt hàng.
Một class cài đặt được nhiều interface — đây là chỗ C# cho phép "đa kế thừa" mà không gặp vấn đề của đa kế thừa cài đặt:
public class OrderRepository : IOrderReader, IOrderWriter, IDisposable { }4.4.2 — Giá trị thật: đảo ngược hướng phụ thuộc
Đây là phần quan trọng nhất, và nó không phải chuyện "có nhiều cài đặt".
Ở sơ đồ dưới, mũi tên từ SqlOrderRepository đi ngược lên phía interface. Interface thuộc về tầng nghiệp vụ, và tầng hạ tầng phải đi theo nó.
Ba thứ có được từ việc đảo hướng này:
- Nghiệp vụ kiểm thử được mà không cần database. Đây là lợi ích cụ thể nhất và cảm nhận được ngay.
- Nghiệp vụ không biết gì về EF Core, Dapper hay SQL. Đổi ORM không làm sập tầng nghiệp vụ.
- Thứ tự viết code đổi chiều. Bạn viết logic nghiệp vụ trước, khai báo thứ nó cần, rồi mới cài đặt — thay vì để cấu trúc bảng quyết định hình dạng nghiệp vụ.
Điểm 3 là điểm hay bị bỏ qua nhưng ảnh hưởng nhiều nhất tới kiến trúc.
4.4.3 — Interface nhỏ, đúng vai
// QUÁ LỚN — người chỉ cần đọc vẫn phải cài đặt cả phần ghi
public interface IOrderService
{
Task<Order> GetByIdAsync(int id);
Task<List<Order>> SearchAsync(OrderFilter f);
Task CreateAsync(Order o);
Task UpdateAsync(Order o);
Task DeleteAsync(int id);
Task<byte[]> ExportToExcelAsync(OrderFilter f);
Task SendConfirmationEmailAsync(int id);
}Hai vấn đề cụ thể:
- Một bản giả trong test phải cài đặt cả bảy phương thức, kể cả khi bài test chỉ dùng một.
ExportToExcelAsyncvàSendConfirmationEmailAsynckhông cùng lý do thay đổi với phần truy cập dữ liệu. Ghép chung nghĩa là đổi định dạng Excel cũng đụng vào interface của repository.
// Chia theo VAI TRÒ của người dùng interface
public interface IOrderReader
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
Task<IReadOnlyList<Order>> SearchAsync(OrderFilter f, CancellationToken ct);
}
public interface IOrderWriter
{
Task CreateAsync(Order o, CancellationToken ct);
Task UpdateAsync(Order o, CancellationToken ct);
}
public interface IOrderExporter
{
Task<byte[]> ExportToExcelAsync(OrderFilter f, CancellationToken ct);
}Một class vẫn cài đặt cả ba nếu muốn. Nhưng người gọi chỉ khai báo phụ thuộc vào phần mình dùng — và nhìn vào constructor của một service là biết ngay nó đọc hay ghi.
Tiêu chí chia: nhóm theo vai trò của người dùng, không nhóm theo class cài đặt.
4.4.4 — Khi nào không cần interface
Phần này ít tài liệu nói, nhưng quan trọng không kém.
// Nghi lễ — interface chỉ để bọc đúng một class, không đảo phụ thuộc gì
public interface IOrderCalculator
{
decimal CalculateTotal(Order o);
}
public class OrderCalculator : IOrderCalculator
{
public decimal CalculateTotal(Order o) => o.Lines.Sum(l => l.Amount);
}Interface trên không mang lại gì: chỉ có một cài đặt, không có ranh giới hạ tầng nào được vượt qua, và hàm thì thuần tuý nên test thẳng cũng được.
Ba câu hỏi để quyết định:
- Nó có vượt qua ranh giới hạ tầng không? (database, HTTP, file, đồng hồ, hàng đợi) → cần.
- Có nhiều cài đặt thật không? → cần.
- Có cần thay bằng bản giả để test thứ khác không? → cần.
Cả ba đều "không" → viết class thẳng. Thêm interface sau cũng chỉ là một thao tác refactor nhỏ.
Đáng chú ý: một trong những chỗ cần interface mà người ta hay quên là đồng hồ. DateTimeOffset.UtcNow là một phụ thuộc hạ tầng trá hình, và nó làm code không test được cho các mốc thời gian cụ thể. .NET 8 đã đưa vào TimeProvider cho đúng việc này.
4.4.5 — Cài đặt tường minh
public class Repository : IReadable, IWritable
{
// Cài đặt ngầm — gọi được qua chính class
public void Read() { }
// Cài đặt TƯỜNG MINH — chỉ gọi được qua interface
void IWritable.Write() { }
}
var repo = new Repository();
repo.Read(); // OK
// repo.Write(); // Lỗi biên dịch
((IWritable)repo).Write(); // OKHai chỗ dùng thật:
- Hai interface có phương thức trùng tên nhưng nghĩa khác nhau.
- Muốn giấu bớt phương thức khỏi API công khai của class — ví dụ
IDisposable.Dispose()cài tường minh để người dùng phải dùngusingthay vì gọi tay.
Dùng vừa phải. Cài đặt tường minh làm code khó đọc hơn nếu không có lý do rõ ràng.
4.4.6 — Default interface method (C# 8+)
public interface ILogger
{
void Log(LogLevel level, string message);
// Cài đặt mặc định — lớp cài đặt không bắt buộc viết lại
void LogError(string message) => Log(LogLevel.Error, message);
}Mục đích ban đầu: thêm phương thức vào interface đã phát hành mà không phá vỡ mọi cài đặt hiện có. Đây là bài toán thật của tác giả thư viện.
Nhưng đừng dùng nó như cách chia sẻ code thông thường. Phương thức mặc định không truy cập được state, không override tự nhiên như class, và làm mờ ranh giới giữa hợp đồng và cài đặt. Trong code ứng dụng, một abstract class hoặc một extension method thường là lựa chọn rõ ràng hơn.
4.4.7 — Rà lại code của bạn
Danh sách rà soát interface
- Mọi phụ thuộc vượt ranh giới hạ tầng đều đi qua interface.
- Không có interface nào chỉ bọc một class mà không vượt ranh giới nào.
- Interface nhóm theo vai trò người dùng, không nhóm theo class cài đặt.
- Không có interface nào buộc bản giả trong test phải cài đặt phương thức không dùng.
- Thời gian hiện tại đi qua TimeProvider hoặc một abstraction, không gọi UtcNow trực tiếp trong logic.
- Interface đặt ở tầng nghiệp vụ, không đặt cùng chỗ với cài đặt hạ tầng.
- Không dùng default interface method như cách chia sẻ code thông thường.
Bài tập áp dụng
Bài 1 — Vẽ lại hướng mũi tên phụ thuộc
Một solution CRM có hai project: Crm.Application (logic nghiệp vụ, có OrderService) và Crm.Infrastructure (truy cập database, có CrmDbContext — lớp của EF Core đại diện cho database, học ở bài 8.10). Hiện OrderService nhận thẳng CrmDbContext qua constructor. Vẽ sơ đồ phụ thuộc giữa hai project. Thêm interface ở tầng nghiệp vụ và vẽ lại. Chỉ ra mũi tên nào đã đổi chiều.
Tiêu chí hoàn thành: bạn chỉ đúng mũi tên đã đảo, và nêu được ai sở hữu interface trong thiết kế mới.
Gợi ý và lời giải — Bài 1
Gợi ý. Câu hỏi quyết định không phải "có interface hay không" mà là interface được khai báo ở dự án nào. Đặt sai chỗ thì mũi tên không đổi chiều, và toàn bộ lợi ích biến mất.
Lời giải — trước:
// Crm.Application/OrderService.cs
public class OrderService
{
private readonly CrmDbContext _db; // phụ thuộc thẳng vào hạ tầng
public OrderService(CrmDbContext db) => _db = db;
}Tầng nghiệp vụ phụ thuộc vào tầng hạ tầng. Hệ quả: đổi từ EF Core sang Dapper là phải sửa tầng nghiệp vụ, và kiểm thử tầng nghiệp vụ cần một database.
Sau:
// Crm.Application/IOrderRepository.cs <- interface SỞ HỮU BỞI TẦNG NGHIỆP VỤ
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
}
// Crm.Infrastructure/EfOrderRepository.cs <- cài đặt ở tầng hạ tầng
public class EfOrderRepository : IOrderRepository
{
private readonly CrmDbContext _db;
public EfOrderRepository(CrmDbContext db) => _db = db;
// ...
}Mũi tên đã đổi chiều là mũi tên giữa Crm.Application và Crm.Infrastructure. Trước: Application → Infrastructure. Sau: Infrastructure → Application (vì hạ tầng phải tham chiếu tới tầng nghiệp vụ để cài đặt interface của nó).
Ai sở hữu interface — đây là toàn bộ điểm mấu chốt. Interface nằm ở Crm.Application, cùng chỗ với người dùng nó, không phải cùng chỗ với người cài đặt nó. Nếu bạn đặt IOrderRepository vào Crm.Infrastructure thì:
Mũi tên vẫn chỉ từ Application sang Infrastructure. Bạn đã thêm một interface mà không đảo được gì — chỉ thêm một file phải mở khi đọc code. Đây là sai lầm phổ biến nhất khi áp dụng nguyên tắc này.
Kiểm chứng bằng file dự án. Mở Crm.Application.csproj và xác nhận nó không tham chiếu tới Crm.Infrastructure:
<!-- Crm.Infrastructure.csproj -->
<ItemGroup>
<ProjectReference Include="..\Crm.Application\Crm.Application.csproj" />
</ItemGroup>
<!-- Crm.Application.csproj — KHÔNG có tham chiếu ngược lại -->Trình biên dịch sẽ ép buộc điều này: tham chiếu vòng giữa hai dự án là lỗi biên dịch. Đó là cách biến một nguyên tắc kiến trúc thành thứ không thể vi phạm thay vì thứ phải nhớ.
Ba lợi ích cụ thể sau khi đảo:
- Kiểm thử tầng nghiệp vụ không cần database. Truyền một bản cài đặt trong bộ nhớ hoặc một bản giả.
- Đổi công nghệ lưu trữ không đụng tầng nghiệp vụ. Viết
DapperOrderRepositoryvà đổi một dòng đăng ký. - Tầng nghiệp vụ diễn đạt theo ngôn ngữ nghiệp vụ.
IOrderRepositorynói vềOrder, không nói vềDbSethaySaveChanges.
Đây là nguyên tắc Dependency Inversion trong SOLID, và là xương sống của Module 16 — Clean Architecture.
Bài 2 — Đếm phương thức thừa trong bản giả
Interface IRepository trong phần "trước" của lời giải có 15 phương thức. EmailService chỉ cần lấy một khách hàng theo id. Đếm số phương thức một bản giả viết tay của IRepository phải cài đặt để test EmailService, so với số phương thức test thật sự gọi. Sau đó tách interface.
Tiêu chí hoàn thành: bạn có tỉ lệ cụ thể, và tách được interface lớn thành các interface nhỏ theo vai sử dụng.
Gợi ý và lời giải — Bài 2
Gợi ý. Tỉ lệ này là thước đo trực tiếp cho nguyên tắc phân tách interface: nếu người dùng chỉ cần 2 trong 15 phương thức, interface đang buộc họ phụ thuộc vào 13 thứ không liên quan.
Lời giải — trước:
public interface IRepository
{
Task<Customer?> GetCustomerAsync(int id, CancellationToken ct);
Task<List<Customer>> GetAllCustomersAsync(CancellationToken ct);
Task AddCustomerAsync(Customer c, CancellationToken ct);
Task DeleteCustomerAsync(int id, CancellationToken ct);
Task<Order?> GetOrderAsync(int id, CancellationToken ct);
Task<List<Order>> GetOrdersByCustomerAsync(int customerId, CancellationToken ct);
Task AddOrderAsync(Order o, CancellationToken ct);
Task<List<Product>> GetProductsAsync(CancellationToken ct);
Task UpdateStockAsync(int productId, int quantity, CancellationToken ct);
Task<Report> GenerateReportAsync(DateTime from, DateTime to, CancellationToken ct);
// ... còn 5 phương thức nữa
}Bài kiểm thử cho EmailService, vốn chỉ cần lấy một khách hàng:
var fakeRepo = new FakeRepository(); // phải cài đặt cả 15 phương thức
// -> 14 phương thức chỉ có dòng: throw new NotImplementedException();Tỉ lệ: dùng 1, cài đặt 15.
Thư viện tạo bản giả tự động (NSubstitute, Moq) giúp khỏi gõ 14 dòng throw, nhưng vấn đề gốc vẫn còn: nhìn constructor EmailService(IRepository repo), người đọc không biết EmailService thật sự phụ thuộc vào cái gì, vì IRepository là một cái tên không nói lên điều gì.
Sau — tách theo vai:
// Crm.Application/Abstractions/
public interface ICustomerReader
{
Task<Customer?> GetByIdAsync(int id, CancellationToken ct);
}
public interface ICustomerWriter
{
Task AddAsync(Customer c, CancellationToken ct);
Task DeleteAsync(int id, CancellationToken ct);
}
public interface IOrderReader { /* ... */ }
public interface IInventoryWriter { /* ... */ }Một lớp hạ tầng vẫn cài đặt nhiều interface cùng lúc, không cần chia nhỏ lớp:
public class EfCustomerRepository : ICustomerReader, ICustomerWriter
{
// một lớp, nhiều vai
}Bài kiểm thử giờ rất rõ:
// Bản giả: cài đặt 1, dùng 1
public class FakeCustomerReader(Customer? customer) : ICustomerReader
{
public Task<Customer?> GetByIdAsync(int id, CancellationToken ct) => Task.FromResult(customer);
}
var service = new EmailService(new FakeCustomerReader(new Customer("An", "[email protected]")));Ba lợi ích, xếp theo giá trị:
- Constructor trở thành tài liệu.
EmailService(ICustomerReader)nói rõ: service này chỉ đọc khách hàng, không ghi gì. Người đọc biết ngay phạm vi ảnh hưởng của nó. - Giảm lý do phải sửa. Thêm phương thức vào
IOrderReaderkhông ảnh hưởng tới các lớp chỉ dùngICustomerReader. - Bản giả ngắn và đúng trọng tâm. Bài kiểm thử không còn nhiễu bởi những phương thức không liên quan.
Đừng tách quá tay. Một interface một phương thức cho mọi thao tác sẽ tạo ra hàng chục file và làm code khó theo dõi. Tiêu chí tách đúng là theo vai sử dụng, không phải theo từng phương thức: nếu ba phương thức luôn được dùng cùng nhau bởi cùng một loại người dùng, chúng thuộc về một interface.
Phép thử: có người dùng nào chỉ cần một phần của interface này không? Có thì tách; không thì để nguyên.
Bài 3 — Tách đồng hồ ra khỏi logic
CommissionService trong phần "trước" của lời giải tính hoa hồng quý hiện tại bằng cách gọi DateTimeOffset.UtcNow bên trong. Đưa TimeProvider vào, rồi viết hai bài kiểm thử: hoa hồng vào đúng 23:59:59 UTC ngày 31/12/2026, và hoa hồng hai giây sau đó (đã sang năm mới).
Tiêu chí hoàn thành: dotnet test xanh, và hai bài kiểm thử cho cùng kết quả dù chạy vào ngày nào, trên máy đặt múi giờ nào.
Gợi ý và lời giải — Bài 3
Gợi ý. TimeProvider là lớp trừu tượng có sẵn từ .NET 8, sinh ra đúng cho việc này. Bản dùng khi chạy thật là TimeProvider.System; bản dùng khi kiểm thử là FakeTimeProvider trong gói Microsoft.Extensions.TimeProvider.Testing (thêm vào project test bằng dotnet add package Microsoft.Extensions.TimeProvider.Testing).
Lời giải — trước:
public class CommissionService
{
public decimal CalculateQuarterlyCommission(Customer customer)
{
var now = DateTimeOffset.UtcNow; // đầu vào ẩn
var quarter = (now.Month - 1) / 3 + 1;
var quarterStart = new DateTimeOffset(now.Year, (quarter - 1) * 3 + 1, 1, 0, 0, 0, TimeSpan.Zero);
return customer.Orders.Where(o => o.CreatedAt >= quarterStart).Sum(o => o.Total) * 0.05m;
}
}Sau — toàn bộ code đặt trong project test (dotnet new xunit), một file là đủ:
using Microsoft.Extensions.Time.Testing;
public class CommissionServiceTests
{
private static Customer CreateCustomerWithOrders()
{
var customer = new Customer("An", "[email protected]");
customer.AddOrder(new Order(new DateTimeOffset(2026, 9, 30, 0, 0, 0, TimeSpan.Zero), 10_000_000m)); // quý 3, không tính
customer.AddOrder(new Order(new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), 20_000_000m)); // quý 4, tính
customer.AddOrder(new Order(new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero), 5_000_000m)); // quý 4, tính
return customer;
}
[Fact]
public void Quarter4Commission_IsCorrect_OnLastSecondOfYear()
{
var time = new FakeTimeProvider(
new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero));
var service = new CommissionService(time);
var commission = service.CalculateQuarterlyCommission(CreateCustomerWithOrders());
Assert.Equal(1_250_000m, commission); // (20 triệu + 5 triệu) * 5%
}
// Kiểm tra ranh giới — phần đáng giá nhất của FakeTimeProvider
[Fact]
public void CrossingNewYear_ResetsTheQuarter()
{
var time = new FakeTimeProvider(
new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero));
var service = new CommissionService(time);
var customer = CreateCustomerWithOrders();
var before = service.CalculateQuarterlyCommission(customer);
time.Advance(TimeSpan.FromSeconds(2)); // bước qua năm mới
var after = service.CalculateQuarterlyCommission(customer);
Assert.Equal(1_250_000m, before);
Assert.Equal(0m, after); // quý 1 năm 2027, chưa có đơn nào
}
}
public class CommissionService
{
private readonly TimeProvider _time;
public CommissionService(TimeProvider time) => _time = time;
public decimal CalculateQuarterlyCommission(Customer customer)
{
var now = _time.GetUtcNow(); // đầu vào tường minh
var quarter = (now.Month - 1) / 3 + 1;
var quarterStart = new DateTimeOffset(now.Year, (quarter - 1) * 3 + 1, 1, 0, 0, 0, TimeSpan.Zero);
return customer.Orders.Where(o => o.CreatedAt >= quarterStart).Sum(o => o.Total) * 0.05m;
}
}
public class Customer
{
private readonly List<Order> _orders = new();
public Customer(string name, string email)
{
Name = name;
Email = email;
}
public string Name { get; }
public string Email { get; }
public IReadOnlyList<Order> Orders => _orders;
public void AddOrder(Order order) => _orders.Add(order);
}
public record Order(DateTimeOffset CreatedAt, decimal Total);Chạy dotnet test: hai test xanh. Không có TimeProvider, test thứ hai không thể tồn tại: bạn phải đợi tới đúng thời khắc giao thừa, hoặc đổi đồng hồ hệ thống của máy chạy CI.
Xem trước — Module 7. Trong ứng dụng thật, CommissionService nhận TimeProvider.System qua dependency injection, đăng ký một dòng builder.Services.AddSingleton(TimeProvider.System);. Module 7 dạy cách này.
Bẫy múi giờ: DateTime không kèm offset. Nếu tạo đơn hàng bằng new DateTime(2026, 10, 1) thay vì DateTimeOffset với TimeSpan.Zero, giá trị đó không nói rõ là giờ ở đâu (Kind = Unspecified). Khi so sánh với quarterStart (một DateTimeOffset theo UTC), .NET ngầm đổi nó sang DateTimeOffset theo múi giờ của máy đang chạy. Trên máy đặt giờ Việt Nam (UTC+7), 00:00 ngày 1/10 thành 17:00 UTC ngày 30/9, rơi sang quý 3 và bị loại. Thử thật một test chỉ có đơn ngày 1/10 tạo bằng new DateTime(2026, 10, 1), kỳ vọng hoa hồng 1.000.000:
Assert.Equal() Failure: Values differ
Expected: 1000000
Actual: 0,00Cùng test đó chạy trên máy CI đặt UTC thì xanh. Đây đúng là loại test "đỏ trên máy tôi, xanh trên CI" mà bài tập này muốn loại bỏ. Quy tắc: thời điểm trong nghiệp vụ dùng DateTimeOffset, và dữ liệu test ghi rõ offset.
Bốn thứ TimeProvider cung cấp, không chỉ thời gian hiện tại:
| Thành viên | Dùng cho |
|---|---|
GetUtcNow() | Thời điểm hiện tại |
GetTimestamp() và GetElapsedTime() | Đo khoảng thời gian, thay cho Stopwatch |
CreateTimer() | Hẹn giờ — kiểm thử được bằng Advance() |
LocalTimeZone | Múi giờ, cũng giả lập được |
Dòng thứ ba đặc biệt giá trị: nó cho phép kiểm thử một tác vụ nền chạy mỗi 5 phút mà không phải chờ 5 phút — chỉ cần gọi Advance(TimeSpan.FromMinutes(5)). Module 14 dùng lại mẫu này cho các tác vụ định kỳ.
Cùng nguyên tắc, áp dụng cho ba nguồn bất định khác. Thời gian chỉ là một trong bốn thứ phá vỡ tính tất định của hàm: thời gian, số ngẫu nhiên, truy cập bên ngoài, và biến tĩnh dùng chung. Cách xử lý giống hệt nhau — biến đầu vào ẩn thành đầu vào tường minh, rồi tiêm nó vào.
Tự kiểm tra
Giá trị thật của interface là gì?
Không phải chuyện có thể có nhiều cài đặt, vì phần lớn interface trong dự án thật chỉ có một. Giá trị nằm ở chỗ nó đảo ngược hướng phụ thuộc: tầng nghiệp vụ định nghĩa thứ nó cần, tầng hạ tầng đi theo. Nhờ đó nghiệp vụ kiểm thử được mà không cần database, và đổi ORM không làm sập tầng nghiệp vụ.
Nên chia interface theo tiêu chí nào?
Theo vai trò của người dùng interface, không theo class cài đặt. Một interface bảy phương thức buộc mọi bản giả trong test phải cài đặt cả bảy dù chỉ dùng một, và ghép những thứ không cùng lý do thay đổi vào một chỗ. Chia thành IOrderReader, IOrderWriter, IOrderExporter thì người gọi chỉ khai báo phần mình dùng.
Khi nào KHÔNG cần interface?
Khi cả ba câu hỏi đều trả lời không: nó không vượt ranh giới hạ tầng nào, không có nhiều cài đặt thật, và không cần thay bằng bản giả để test thứ khác. Một IFooService chỉ bọc FooService là nghi lễ chứ không phải thiết kế. Thêm interface sau cũng chỉ là một thao tác refactor nhỏ.
Vì sao thời gian hiện tại nên đi qua abstraction?
Vì DateTimeOffset.UtcNow là một phụ thuộc hạ tầng trá hình: nó làm hàm không test được cho các mốc thời gian cụ thể, ví dụ hành vi vào đúng cuối năm hay ngày 29 tháng 2. .NET 8 đã đưa vào TimeProvider đúng cho việc này.
Cài đặt tường minh dùng khi nào?
Hai trường hợp: khi hai interface có phương thức trùng tên nhưng nghĩa khác nhau, và khi muốn giấu bớt một phương thức khỏi API công khai của class — ví dụ cài tường minh IDisposable.Dispose để buộc người dùng dùng using. Ngoài hai trường hợp đó thì nó chỉ làm code khó đọc hơn.
Default interface method sinh ra để làm gì?
Để tác giả thư viện thêm được phương thức vào interface đã phát hành mà không phá vỡ mọi cài đặt hiện có. Nó không phải cách chia sẻ code thông thường: phương thức mặc định không truy cập được state, không override tự nhiên như class, và làm mờ ranh giới giữa hợp đồng với cài đặt.
Kết luận
Ba điều đáng nhớ nhất:
- Interface để đảo hướng phụ thuộc, không phải để đếm số cài đặt.
- Chia theo vai trò người dùng. Interface bảy phương thức là bảy lý do để thay đổi gộp vào một chỗ.
- Không phải class nào cũng cần interface. Ba câu hỏi ở mục 4.4.4 trả lời được điều đó trong 10 giây.
Tham khảo
- Interfaces — C#
- Object-oriented programming in C#
- Framework design guidelines — chọn giữa interface và abstract class
- Singleton, Scoped hay Transient? — đăng ký cài đặt cho interface
Điều hướng
- Bài trước: 4.3 — Inheritance và Polymorphism
- Bài tiếp theo: 4.5 — Abstraction
- Về module: Trang mục lục
Bài liên quan
- Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi — Ba lifetime trong DI container của ASP.NET Core khác nhau ở thời điểm tạo và thời điểm dispose instance.