4.6 - Access Modifiers
Tóm tắt: C# có sáu mức truy cập dùng cho thành viên và kiểu, cộng thêm
file(C# 11) chỉ dùng cho kiểu. Điều đáng nhớ nhất không phải danh sách mà là mức mặc định khi bạn không viết gì: thành viên của class mặc định làprivate, còn kiểu ở cấp namespace mặc định làinternal. Nghĩa là mộtclass Orderkhông có từ khoá nào không dùng được từ project khác — nguồn của câu hỏi "vì sao tôi không thấy class này". Bài này cũng làm rõ cặp dễ nhầm nhất,protected internal(là HOẶC) vàprivate protected(là VÀ), cùng cách mở cửa cho unit test mà không biến mọi thứ thànhpublic.
Mục tiêu bài học
Sau bài này bạn có thể:
- Liệt kê sáu mức truy cập và phạm vi của từng mức.
- Nói được mức mặc định trong từng ngữ cảnh khi không viết từ khoá nào.
- Phân biệt
protected internalvớiprivate protected. - Cho test truy cập thành viên
internalmà không phải đổi sangpublic. - Chọn mức truy cập theo nguyên tắc đóng trước, mở sau.
Cần biết trước: bài 4.4 (interface, đảo ngược phụ thuộc) và solution nhiều project ở bài 0.4. Assembly là file .dll (hoặc .exe) mà một project biên dịch ra; mỗi project trong solution là một assembly riêng.
Nội dung bài học
4.6.1 — Sáu mức
| Mức | Ai truy cập được |
|---|---|
private | Chỉ trong chính kiểu đó |
protected | Kiểu đó và các lớp con |
internal | Mọi nơi trong cùng assembly |
protected internal | Cùng assembly HOẶC lớp con ở assembly khác |
private protected | Lớp con VÀ phải cùng assembly |
public | Mọi nơi |
Hai dòng giữa là chỗ hay nhầm:
public class Base
{
protected internal int A; // rộng hơn: cùng assembly HOẶC lớp con bất kỳ
private protected int B; // hẹp hơn: lớp con VÀ cùng assembly
}Mẹo nhớ: đọc dấu cách giữa hai từ như một phép toán. protected internal = protected hoặc internal. private protected = giao của hai điều kiện, nên hẹp nhất trong nhóm.
Trong thực tế private protected rất hiếm dùng; protected internal cũng vậy. Đừng dùng chúng chỉ vì chúng tồn tại.
Mức thứ bảy, file (C# 11), chỉ đặt được trước một kiểu (file class Helper { }) và giới hạn kiểu đó trong đúng file mã nguồn chứa nó. Nó chủ yếu dành cho code do công cụ sinh ra (source generator), nên bài 4.10 chỉ nhắc qua.
4.6.2 — Mức mặc định: nguồn của nhiều bối rối
class Order { } // internal — KHÔNG thấy từ project khác
public class Customer
{
int _id; // private
void Calculate() { } // private
public string Name { get; } // public, vì đã viết rõ
}
interface IRepository
{
void Save(); // public — mặc định của thành viên interface
}Từ C# 8, thành viên interface có thể khai báo private, protected hay internal (dùng cho phương thức có thân sẵn trong interface, xem mục 4.4.6). Nhưng không viết gì thì nó là public, và phương thức không có thân mà lớp cài đặt phải viết thì gần như luôn để public.
Bảng mặc định khi bạn không viết gì:
| Ngữ cảnh | Mặc định |
|---|---|
| Kiểu ở cấp namespace (class, record, interface, enum) | internal |
| Thành viên của class / struct | private |
| Thành viên của interface | public (có thể khai báo mức khác từ C# 8) |
| Thành viên của enum | public |
Câu hỏi "vì sao project kia không thấy class của tôi" gần như luôn có cùng một câu trả lời: bạn quên chữ public, và nó đang là internal.
4.6.3 — internal: ranh giới của assembly
internal là mức bị dùng ít hơn mức nó đáng được dùng.
// Trong project MyApp.Infrastructure
public interface IOrderRepository { /* ... */ } // hợp đồng — public
internal class EfOrderRepository : IOrderRepository // cài đặt — internal
{
// Project khác không new được class này.
// Chúng chỉ lấy được qua một điểm public, dưới dạng IOrderRepository.
}
// Điểm public duy nhất: nằm trong assembly này nên được phép new EfOrderRepository
public static class InfrastructureFactory
{
public static IOrderRepository CreateOrderRepository() => new EfOrderRepository();
}Trong ứng dụng ASP.NET Core, "điểm public duy nhất" ấy thường là một hàm đăng ký với dependency injection (services.AddScoped<IOrderRepository, EfOrderRepository>()), học ở Module 7. Ý tưởng vẫn y hệt.
Mẫu này rất đáng dùng: hợp đồng public, cài đặt internal. Nó biến ranh giới kiến trúc thành thứ trình biên dịch ép buộc, thay vì chỉ là quy ước trong tài liệu. Một người viết code ở tầng khác không thể vô tình phụ thuộc vào lớp cài đặt, vì nó không nhìn thấy được.
4.6.4 — Cho test truy cập internal
Vấn đề quen thuộc: bạn muốn test một class internal nhưng project test là assembly khác.
Cách sai là đổi nó thành public — bạn vừa phơi chi tiết cài đặt ra cho toàn bộ giải pháp chỉ vì lý do kiểm thử.
Cách đúng:
<!-- Trong .csproj của project cần test -->
<ItemGroup>
<InternalsVisibleTo Include="MyApp.Infrastructure.Tests" />
</ItemGroup>Hoặc dạng attribute cũ hơn, đặt trong AssemblyInfo.cs:
[assembly: InternalsVisibleTo("MyApp.Infrastructure.Tests")]Chỉ đúng assembly được nêu tên mới nhìn thấy. Phần còn lại của giải pháp vẫn bị chặn.
Một lưu ý về triết lý: nếu bạn thường xuyên phải mở internal ra để test, hãy tự hỏi có phải mình đang test chi tiết cài đặt thay vì test hành vi qua API công khai. Đôi khi câu trả lời là có, và bài test đó sẽ vỡ mỗi lần refactor dù hành vi không đổi.
4.6.5 — Mức truy cập của accessor
public class Order
{
public decimal Total { get; private set; } // đọc công khai, ghi nội bộ
public string Code { get; init; } // chỉ gán lúc khởi tạo
protected internal int Version { get; private set; }
}Quy tắc: mức của accessor phải hẹp hơn hoặc bằng mức của property.
private decimal Total { get; public set; } // Lỗi biên dịch: set rộng hơn propertyerror CS0273: The accessibility modifier of the 'Order.Total.set' accessor must be more restrictive than the property or indexer 'Order.Total'{ get; private set; } là dạng hữu dụng nhất trong nhóm này: bên ngoài đọc được giá trị nhưng chỉ phương thức của class mới thay đổi được — tức là mọi thay đổi đều đi qua một quy tắc, đúng tinh thần đóng gói ở bài 4.2.
4.6.6 — Đóng trước, mở sau
Nguyên tắc: bắt đầu ở mức đóng nhất, chỉ mở khi có nhu cầu thật.
Lý do thực dụng, không phải triết lý:
- Mở rộng quyền truy cập là thay đổi tương thích ngược.
private→publickhông phá vỡ code nào. - Siết lại thì phá vỡ mọi chỗ đang gọi.
public→internalcó thể làm hỏng cả những project bạn không kiểm soát.
Nói cách khác, bạn luôn mở ra được, nhưng đóng lại thì rất tốn. Nên mặc định chọn chặt.
Với thư viện dùng chung, điều này còn quan trọng hơn: mọi thứ public là một lời hứa bạn phải giữ ở các phiên bản sau, và phá vỡ nó buộc tăng số major theo SemVer.
4.6.7 — Rà lại code của bạn
Danh sách rà soát mức truy cập
- Không có class nào public chỉ vì quên nghĩ; mỗi public là một quyết định.
- Lớp cài đặt của interface được khai internal khi không cần dùng ngoài assembly.
- Không có field nào public; chỉ có property.
- Test truy cập internal qua InternalsVisibleTo, không bằng cách đổi thành public.
- Property nghiệp vụ dùng { get; private set; } thay vì { get; set; }.
- Không dùng protected internal hay private protected nếu không có lý do cụ thể.
- Thư viện dùng chung có API public tối thiểu, vì mỗi public là một cam kết.
Bài tập áp dụng
Bài 1 — Đếm public thừa
Project Crm.Infrastructure của một đội có các class public sau. Cột bên phải cho biết project khác (Crm.Api, Crm.Application) có dùng trực tiếp class đó không. Quyết định class nào nên đổi thành internal, và class nào phải giữ public.
Class trong Crm.Infrastructure | Project khác dùng trực tiếp? |
|---|---|
EfOrderRepository (cài IOrderRepository) | Không, chỉ dùng qua interface |
SmtpEmailSender (cài IEmailSender) | Không, chỉ dùng qua interface |
DbErrorMapper (dịch mã lỗi database) | Không |
CrmDbContext | Không |
ServiceCollectionExtensions (hàm đăng ký mọi thứ ở trên) | Có, Crm.Api gọi lúc khởi động |
Tiêu chí hoàn thành: bạn chọn đúng những class đổi được, giải thích vì sao class cuối phải giữ public, và nêu được lợi ích ngoài chuyện "gọn hơn". Nếu đã làm Bài 2, thử đổi thật trong solution của Bài 2 và build lại cho xanh.
Gợi ý và lời giải — Bài 1
Gợi ý. Trong dự án thật, cách nhanh nhất để biết một class có bị project khác dùng không là đổi nó thành internal rồi build: project nào dùng nó sẽ báo lỗi CS0122. IDE cũng có "Find All References" (Shift+F12).
Lời giải. Bốn class đầu không project nào dùng trực tiếp, nên đổi được:
internal sealed class EfOrderRepository : IOrderRepository { }
internal sealed class SmtpEmailSender : IEmailSender { }
internal static class DbErrorMapper { }
internal sealed class CrmDbContext : DbContext { }Interface vẫn public vì tầng nghiệp vụ cần chúng — nhưng interface nằm ở Crm.Application, không nằm ở đây, đúng như bài 4.4 đã phân tích. Vậy thứ duy nhất Crm.Infrastructure cần công khai là lớp đăng ký mà Crm.Api gọi lúc khởi động (cú pháp đăng ký dependency injection học ở Module 7, ở đây chỉ cần thấy nó là điểm public duy nhất):
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCrmInfrastructure(
this IServiceCollection services, IConfiguration config)
{
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IEmailSender, SmtpEmailSender>();
return services;
}
}Ba lợi ích ngoài chuyện gọn:
Trình biên dịch trở thành người canh gác kiến trúc. Một lập trình viên mới muốn
new EfOrderRepository()thẳng từ controller sẽ nhận lỗi biên dịch, chứ không phải một lời nhắc trong code review mà có thể bị bỏ qua.Tự do sửa đổi bên trong. Thứ
internalchỉ có thể bị dùng trong cùng assembly, nên bạn đổi tên, đổi chữ ký, tách lớp mà không sợ làm hỏng chỗ nào ngoài tầm mắt. Đây là lợi ích lớn nhất về lâu dài.Trình biên dịch tối ưu tốt hơn. Kết hợp
internal sealed, JIT biết chắc không có lớp con nào, nên có thể nội tuyến các lời gọi phương thức ảo. Lợi ích nhỏ nhưng miễn phí.
Quy tắc thực dụng: đóng trước, mở sau.
| Bắt đầu bằng | Nâng lên khi |
|---|---|
private | Lớp khác trong cùng file cần |
internal | Project khác thật sự cần |
public | Có người dùng bên ngoài giải pháp |
Hạ một thành viên từ public xuống internal là thay đổi phá vỡ với mọi người đang dùng. Nâng từ internal lên public thì không phá vỡ gì. Vì vậy chiều an toàn là bắt đầu chặt rồi nới ra.
Một trường hợp cần chú ý. Nếu bạn phát hành thư viện qua NuGet, public là hợp đồng công khai theo đúng nghĩa: mọi thứ public đều phải giữ tương thích theo quy tắc phiên bản ngữ nghĩa. Lúc đó việc giữ bề mặt công khai nhỏ không còn là chuyện gọn gàng mà là chuyện giảm gánh nặng bảo trì trong nhiều năm.
Bài 2 — Dựng ranh giới bằng trình biên dịch
Dựng một solution ba project như bài 0.4: Crm.Application (class library, chứa interface), Crm.Infrastructure (class library, chứa lớp cài đặt, tham chiếu Crm.Application) và Crm.Api (console, tham chiếu cả hai). Đặt lớp repository là internal, chỉ để interface public. Thử new lớp repository từ Crm.Api và ghi lại lỗi biên dịch.
dotnet new sln -n Crm
dotnet new classlib -n Crm.Application -o src/Crm.Application
dotnet new classlib -n Crm.Infrastructure -o src/Crm.Infrastructure
dotnet new console -n Crm.Api -o src/Crm.Api
dotnet sln add src/Crm.Application src/Crm.Infrastructure src/Crm.Api
dotnet add src/Crm.Infrastructure reference src/Crm.Application
dotnet add src/Crm.Api reference src/Crm.Application src/Crm.InfrastructureTiêu chí hoàn thành: bạn ghi lại được mã lỗi, và nêu được vì sao cách này mạnh hơn quy ước của đội.
Gợi ý và lời giải — Bài 2
Gợi ý. Ranh giới kiến trúc chỉ có giá trị khi nó không thể vượt qua. Quy ước ghi trong tài liệu thì ai cũng vượt được — vô tình hoặc lúc gấp.
Lời giải.
// src/Crm.Application/IOrderRepository.cs
namespace Crm.Application;
public interface IOrderRepository
{
string? FindCode(int id);
}// src/Crm.Infrastructure/EfOrderRepository.cs
using Crm.Application;
namespace Crm.Infrastructure;
internal sealed class EfOrderRepository : IOrderRepository
{
public string? FindCode(int id) => id == 1 ? "DH-001" : null; // tạm thời, chưa có database
}
public static class InfrastructureFactory
{
public static IOrderRepository CreateOrderRepository() => new EfOrderRepository();
}Thử từ Crm.Api:
// src/Crm.Api/Program.cs
using Crm.Application;
using Crm.Infrastructure;
IOrderRepository repo = InfrastructureFactory.CreateOrderRepository(); // hợp lệ
Console.WriteLine(repo.FindCode(1));
var direct = new EfOrderRepository(); // lỗiLỗi biên dịch thật trên .NET 10:
Program.cs(7,18): error CS0122: 'EfOrderRepository' is inaccessible due to its protection levelCách duy nhất còn lại để lấy được nó là qua điểm public mà Crm.Infrastructure chủ động mở ra, tức là qua interface. Xoá dòng lỗi đi thì chương trình in DH-001.
Vì sao mạnh hơn quy ước của đội:
| Quy ước trong tài liệu | Ranh giới bằng trình biên dịch | |
|---|---|---|
| Phát hiện vi phạm | Khi có người để ý trong review | Ngay khi gõ |
| Người mới vào đội | Phải đọc tài liệu và nhớ | Không thể sai |
| Lúc gấp, 2 giờ sáng | Dễ bị bỏ qua | Không bỏ qua được |
| Sau hai năm | Thường đã bị quên | Vẫn nguyên hiệu lực |
Nguyên tắc chung: biến quy ước thành ràng buộc kỹ thuật bất cứ khi nào có thể. Đây là cùng một ý với việc bọc string thành kiểu riêng ở bài 4.7, và dùng constructor để chặn dữ liệu sai ở bài 4.1 — đẩy việc kiểm tra từ "phải nhớ" sang "không thể sai".
Ba mức ranh giới, từ yếu tới mạnh:
Tài liệu — yếu nhất, dựa hoàn toàn vào kỷ luật.
internaltrong assembly riêng — trình biên dịch ép buộc. Đây là mức thực dụng nhất cho phần lớn dự án.Kiểm tra kiến trúc tự động — mạnh nhất, kiểm được cả những ràng buộc mà
internalkhông diễn đạt được. Xem trước một bài kiểm thử dùng thư việnNetArchTest.Rules(giấy phép MIT):csharp[Fact] public void ApplicationLayer_MustNotDependOnEfCore() { var result = Types.InAssembly(typeof(OrderService).Assembly) .ShouldNot().HaveDependencyOn("Microsoft.EntityFrameworkCore") .GetResult(); Assert.True(result.IsSuccessful, $"Vi phạm: {string.Join(", ", result.FailingTypeNames ?? [])}"); }Ràng buộc kiến trúc trở thành bài kiểm thử chạy trong CI. Module 16 dùng kỹ thuật này để bảo vệ quy tắc phụ thuộc của Clean Architecture.
Chi phí cần cân nhắc. Tách thành nhiều assembly làm thời gian build tăng lên và cấu trúc giải pháp phức tạp hơn. Với dự án nhỏ một người, chi phí đó có thể lớn hơn lợi ích. Với dự án nhiều người và sống lâu, nó gần như luôn đáng.
Bài 3 — Mở cửa đúng cách cho project test
Trong solution của Bài 2, thêm project test tests/Crm.Infrastructure.Tests (dotnet new xunit, tham chiếu Crm.Infrastructure) và một lớp internal static class DbErrorMapper vào Crm.Infrastructure. Thêm InternalsVisibleTo cho project test, viết một bài kiểm thử chạm vào DbErrorMapper. Sau đó thử dùng DbErrorMapper từ Crm.Api và xác nhận nó vẫn bị chặn.
Tiêu chí hoàn thành: project test truy cập được, project thứ ba vẫn bị chặn, và bạn nêu được khi nào không nên dùng cách này.
Gợi ý và lời giải — Bài 3
Gợi ý. InternalsVisibleTo mở quyền truy cập internal cho đúng một assembly được nêu tên, không mở cho tất cả.
Lời giải — khai báo trong file .csproj, cách hiện đại:
<!-- Crm.Infrastructure.csproj -->
<ItemGroup>
<InternalsVisibleTo Include="Crm.Infrastructure.Tests" />
</ItemGroup>Hoặc cách cũ, bằng thuộc tính trong mã nguồn:
[assembly: InternalsVisibleTo("Crm.Infrastructure.Tests")]Lớp internal cần test:
// src/Crm.Infrastructure/DbErrorMapper.cs
namespace Crm.Infrastructure;
internal static class DbErrorMapper
{
// 23505 là mã lỗi "vi phạm ràng buộc duy nhất" của PostgreSQL
internal static bool IsUniqueViolation(string sqlState) => sqlState == "23505";
}Giờ project test truy cập được:
// tests/Crm.Infrastructure.Tests/DbErrorMapperTests.cs
using Crm.Infrastructure;
namespace Crm.Infrastructure.Tests;
public class DbErrorMapperTests
{
[Fact]
public void DetectsPostgresUniqueViolation()
{
Assert.True(DbErrorMapper.IsUniqueViolation("23505")); // lớp internal
}
}Chưa thêm InternalsVisibleTo thì chính project test cũng báo error CS0122: 'DbErrorMapper' is inaccessible due to its protection level. Thêm vào rồi thì dotnet test xanh, còn Crm.Api vẫn bị chặn:
error CS0122: 'DbErrorMapper' is inaccessible due to its protection levelĐúng như mong muốn: mở đúng một cánh cửa, không mở toang.
Với assembly có chữ ký mạnh, phải kèm khoá công khai:
<InternalsVisibleTo Include="Crm.Infrastructure.Tests, PublicKey=0024000004800000..." />Lấy khoá bằng sn -Tp Crm.Infrastructure.Tests.dll.
Khi nào không nên dùng — đây là phần chính của bài.
InternalsVisibleTo rất tiện, và chính vì tiện nên dễ bị lạm dụng. Ba tình huống nên dừng lại và nghĩ lại:
Khi bạn dùng nó để kiểm thử logic nghiệp vụ. Logic nghiệp vụ nên nằm ở tầng nghiệp vụ với API công khai rõ ràng, và kiểm thử được qua API đó. Phải với tay vào
internalđể kiểm thử nghiệp vụ là dấu hiệu logic đang nằm sai chỗ.Khi bài kiểm thử bám vào chi tiết cài đặt. Kiểm thử qua
internalthường kiểm tra cách làm thay vì kết quả. Hệ quả là mỗi lần tái cấu trúc — dù hành vi không đổi — bài kiểm thử lại hỏng. Bộ kiểm thử như vậy trở thành gánh nặng thay vì lưới an toàn.Khi nó trở thành lối tắt. Thêm
InternalsVisibleTocho project thứ hai, rồi thứ ba, và ranh giới bạn vừa dựng ở bài 2 không còn gì.
Dùng nó cho việc gì thì hợp lý:
| Hợp lý | Không hợp lý |
|---|---|
| Kiểm thử lớp tiện ích hạ tầng, ví dụ ánh xạ mã lỗi | Kiểm thử quy tắc nghiệp vụ |
| Kiểm thử thuật toán phức tạp không muốn công khai | Vì công khai hơn thì viết test dễ hơn |
| Kiểm thử các trường hợp biên khó dựng qua API công khai | Vì lười thiết kế API công khai cho tử tế |
Phép thử nhanh. Tự hỏi: nếu tôi tái cấu trúc phần bên trong mà không đổi hành vi, bài kiểm thử này có hỏng không? Có thì nó đang kiểm thử sai thứ, và InternalsVisibleTo chỉ đang giúp bạn làm sai một cách thuận tiện hơn.
Tự kiểm tra
Mức truy cập mặc định trong từng ngữ cảnh là gì?
Kiểu ở cấp namespace mặc định là internal. Thành viên của class và struct mặc định là private. Thành viên của interface mặc định là public (từ C# 8 có thể khai báo mức khác), thành viên của enum luôn public. Vì vậy một class không có từ khoá nào sẽ không dùng được từ project khác — đó là câu trả lời cho phần lớn thắc mắc vì sao không thấy class này.
protected internal khác private protected ở đâu?
protected internal là phép HOẶC: cùng assembly hoặc là lớp con ở bất kỳ assembly nào, nên nó rộng hơn cả hai. private protected là phép VÀ: phải là lớp con và phải cùng assembly, nên nó hẹp nhất trong nhóm. Cả hai đều hiếm dùng trong thực tế.
Mẫu hợp đồng public, cài đặt internal có lợi gì?
Nó biến ranh giới kiến trúc thành thứ trình biên dịch ép buộc thay vì chỉ là quy ước trong tài liệu. Người viết code ở tầng khác không thể vô tình phụ thuộc vào lớp cài đặt vì họ không nhìn thấy nó; họ chỉ lấy được qua dependency injection dưới dạng interface.
Làm sao cho project test truy cập được thành viên internal?
Dùng InternalsVisibleTo trong file csproj hoặc dưới dạng attribute trong AssemblyInfo, nêu đích danh tên assembly test. Chỉ assembly được nêu tên mới nhìn thấy, phần còn lại vẫn bị chặn. Đổi sang public chỉ để test là phơi chi tiết cài đặt ra cho toàn bộ giải pháp.
Nếu phải mở internal ra để test thường xuyên thì sao?
Đó là dấu hiệu bạn đang test chi tiết cài đặt thay vì test hành vi qua API công khai. Những bài test như vậy sẽ vỡ mỗi lần refactor dù hành vi không đổi, nên chúng làm chậm việc cải tiến code thay vì bảo vệ nó.
Vì sao nên bắt đầu ở mức truy cập đóng nhất?
Vì mở rộng quyền truy cập là thay đổi tương thích ngược, không phá vỡ code nào. Còn siết lại thì làm hỏng mọi chỗ đang gọi, kể cả những project bạn không kiểm soát. Với thư viện dùng chung, mỗi thành viên public là một cam kết phải giữ ở các phiên bản sau.
Kết luận
Ba điều đáng nhớ nhất:
- Mặc định là
internalcho kiểu,privatecho thành viên. Biết điều này là hết bối rối. - Hợp đồng
public, cài đặtinternal. Cách rẻ nhất để trình biên dịch giữ giúp ranh giới kiến trúc. - Mở thì dễ, đóng thì đau. Luôn bắt đầu ở mức chặt nhất.
Tham khảo
- Access modifiers — C# reference
- Properties — mức truy cập của accessor
- Framework design guidelines — thiết kế API công khai
- Semantic Versioning — vì sao
publiclà một cam kết
Điều hướng
- Bài trước: 4.5 — Abstraction
- Bài tiếp theo: 4.7 — Records và Value Objects (C# 9+)
- Về module: Trang mục lục