C# access modifiers including private protected and file, defaults per declaration, accessor-level modifiers, InternalsVisibleTo, and using accessibility to keep API surface small.
C# has seven accessibility levels. public: anyone. private: only the containing type (the default for members). protected: the type and derived types. internal: the same assembly (the default for top-level types). protected internal: derived types or the same assembly. private protected (C# 7.2): derived types and in the same assembly. file (C# 11): a type visible only in its source file, mainly for source generators. You can also restrict a single accessor (public string Name { get; private set; }). [InternalsVisibleTo] opens internals to named assemblies such as tests. Access modifiers are a design tool, not a security boundary: reflection can bypass them.
Access modifiers control who can see and use a type or member. They're the main tool for encapsulation at every scale: inside a class, across an assembly, and at the public API boundary. I'd go through all seven, the defaults, InternalsVisibleTo, and the principle I follow: start as restrictive as possible and widen only when needed.
| Modifier | Accessible from | Typical use |
|---|---|---|
public |
Anywhere | The deliberate API surface |
private |
The containing type only (including nested types) | Implementation details, state |
protected |
The containing type and derived types (any assembly) | Extension hooks for subclasses |
internal |
Any code in the same assembly | Types and members shared within a project but hidden from consumers |
protected internal |
Same assembly OR derived types anywhere | Rare; framework extension points also used internally |
private protected (C# 7.2) |
Derived types in the same assembly only | Hooks for internal subclasses without exposing them publicly |
file (C# 11, types only) |
The same source file | Helpers, especially in source generators, avoiding name clashes |
protected internal is a union (either condition); private protected is an intersection (both conditions).
| Declaration | Default |
|---|---|
| Top-level class, struct, record, interface, enum, delegate | internal |
| Nested type | private |
| Class/struct/record members | private |
| Interface members | public (can be explicitly restricted since C# 8) |
| Enum members | public (always) |
| Explicit interface implementations | No modifier allowed (accessible via the interface) |
Being explicit is clearer — analysers (IDE0040) can enforce "always specify accessibility".
public in a library is something you must support and can't change without breaking consumers. Keep it minimal: the types and methods consumers need.internal by default for application projects: a typical solution splits Domain, Application, Infrastructure and API projects. Infrastructure implementations (SqlOrderRepository, StripePaymentGateway) can be internal, exposed only through an AddInfrastructure(this IServiceCollection) extension method. Consumers can't depend on implementation details.private state, public operations inside types (encapsulation).private set / init to control mutation of properties.protected sparingly — every protected member is a contract with subclasses in other assemblies.file for helper types that should never leak, including generator output.InternalsVisibleTo<ItemGroup>
<InternalsVisibleTo Include="Ordering.Tests" />
</ItemGroup>
Makes internal members visible to a named "friend" assembly (signed assemblies need the public key).
internal types without making them public; sharing internals between tightly coupled assemblies of the same product.InternalsVisibleTo for internal components that are meaningful units on their own (parsers, calculators).InternalsVisibleTo("DynamicProxyGenAssembly2") to mock internal interfaces.A shared "Common" library had every type public by default, and other teams started depending on its internal helpers (a SqlRetryHelper, a JsonUtil). When we needed to change those helpers, we couldn't without breaking five services. We introduced a rule: new types are internal sealed unless there's a reason; we added a public API analyser (Microsoft.CodeAnalysis.PublicApiAnalyzers with PublicAPI.Shipped.txt) so any change to the public surface shows up in code review; and we gradually deprecated public helpers with [Obsolete] before internalising them in the next major version.
Checks accuracy on all seven levels and the judgement to keep APIs small.
Green flags: correct union/intersection explanation; defaults for each declaration kind; accessor modifiers; file types; InternalsVisibleTo; not a security boundary; internal-by-default for libraries.
Red flags: mixes up protected internal and private protected; thinks the default for classes is private or public; everything public; protected fields.
MethodAccessException — unless reflection with BindingFlags.NonPublic is used, which bypasses compile-time checks but not (in full trust) the ability to call. Since .NET Core, reflection on private members works; trimming/AOT may remove members accessed only via reflection.UnsafeAccessorAttribute (.NET 8) lets code access private members of other types without reflection, efficiently — intended for serialisers and test helpers. It's a deliberate escape hatch; don't use it to work around poor API design.IL flags: public, private, family (protected), assembly (internal), famorassem (protected internal), famandassem (private protected), plus nested variants for nested types. The file modifier is a C# concept implemented by giving the type a mangled, unique name (<FileName>F..__Helper) and internal accessibility.
A member's effective accessibility is the intersection of its own and its containing type's: a public method on an internal class is effectively internal. The compiler enforces consistency: a public method can't expose an internal type in its signature (CS0050/CS0051), because consumers couldn't use it.
protected access rulesA derived class can access a protected member only through an instance of its own type (or further derived), not through any base-typed instance:
class Base { protected int X; }
class Derived : Base
{
void M(Base b, Derived d) { _ = d.X; /* ok */ _ = b.X; /* CS1540 error */ }
}
This prevents a subclass from reaching into sibling subclasses' protected state.
<EnablePackageValidation>true</EnablePackageValidation>) compares against a baseline package version to detect breaking changes.[EditorBrowsable(EditorBrowsableState.Never)] hides public members from IntelliSense when they must be public for technical reasons.[Obsolete] with a diagnostic ID for staged removals.Testing through public APIs keeps tests resilient to refactoring. Expose internals to tests when a component is complex and stable enough to deserve its own tests (e.g. a tax rules engine) but shouldn't be public. Avoid making things public just for tests — it permanently widens the contract.
internal and expose only a DI registration extension.Microsoft.CodeAnalysis.PublicApiAnalyzers to a library and see the diagnostics.file class in two files with the same name and confirm no conflict.// Library: public surface is small, everything else internal
public sealed class OrderService(IOrderStore store)
{
public async Task<OrderId> PlaceAsync(PlaceOrder cmd, CancellationToken ct)
{
var order = OrderFactory.Create(cmd); // internal helper
await store.SaveAsync(order, ct);
return order.Id;
}
}
internal static class OrderFactory
{
internal static Order Create(PlaceOrder cmd) => new(OrderId.New(), cmd.CustomerId, cmd.Lines);
}
public class Account
{
public decimal Balance { get; private set; } // read anywhere, write here only
protected void Credit(decimal amount) => Balance += amount; // for subclasses
private protected void Audit(string what) { } // subclasses in THIS assembly only
protected internal virtual void OnClosed() { } // subclasses OR same assembly
}
// File-local type: no clash with other files' Helper types
file sealed class Helper
{
public static string Normalise(string s) => s.Trim().ToUpperInvariant();
}
<!-- Orders.csproj -->
<ItemGroup>
<InternalsVisibleTo Include="Orders.Tests" />
</ItemGroup>
What happens: IDE templates or habit produce public types and members everywhere.
Why it is wrong: the whole implementation becomes a contract consumers depend on.
Do instead: default to internal sealed and private; widen deliberately.
What happens: public decimal Balance { get; set; }.
Why it is wrong: callers can bypass invariants.
Do instead: private set or init, with methods that enforce rules.
protectedWhat happens: fields and helpers marked protected "for subclasses".
Why it is wrong: creates a large inheritance contract across assemblies.
Do instead: keep state private; expose a few deliberate protected hooks or use private protected.
InternalsVisibleToWhat happens: many assemblies are friends, or tests target tiny private details. Why it is wrong: blurs boundaries and makes tests brittle. Do instead: test through public behaviour; grant friend access sparingly.
What happens: private methods made public to assert on them. Why it is wrong: permanently widens the API. Do instead: test via public behaviour or extract a separate internal type.
protected internal and private protectedWhat happens: choosing the wrong one and exposing members to external subclasses.
Why it is wrong: protected internal is broader (union), not narrower.
Do instead: remember: protected internal = either; private protected = both.
What happens: public methods returning internal types. Why it is wrong: compile errors, or pressure to make internal types public. Do instead: design the public surface with public types only, keeping implementations internal.
What happens: breaking changes slip into NuGet packages. Why it is wrong: consumers break on upgrade. Do instead: use PublicApiAnalyzers and package validation.
Seniors use accessibility to control coupling, especially in shared libraries and modular monoliths.
| Context | Senior default |
|---|---|
| Shared library | internal by default, public API tracked by the analyser |
| Modular monolith | Each module exposes only contracts; internals hidden; architecture tests enforce it |
| Inheritance hooks | protected methods, never fields; private protected to keep hooks inside the assembly |
| Test access | InternalsVisibleTo sparingly; prefer testing through the public surface |
| Generated code | file types to avoid name clashes |
They see every public keyword as a cost: it is a promise to keep that member stable.
Encapsulation means an object owns its state and only lets it change through behaviour that keeps it valid. In C# you keep fields private, expose read access through properties (often get-only or private set), and offer methods like Withdraw(amount) that check rules before changing state, instead of a public setter on Balance. Collections are exposed as IReadOnlyList<T> with Add... methods. The benefit is that invariants ("balance never goes below the overdraft limit") are enforced in one place, so callers cannot put the object into an invalid state, and the internals can change without breaking callers.
A nested type is a class, struct, record, interface, enum or delegate declared inside another type. It is a member of the outer type, so its default accessibility is private, and it can access all members of the outer type, including private ones. Unlike a Java inner class, a C# nested class has no implicit reference to an outer instance: to use outer instance members you must pass the outer object in. Nested types are good for private implementation details (an enumerator, a comparer, a state object, a builder) and for tightly coupled helpers. Public nested types are usually discouraged (CA1034) because callers must write Outer.Inner and the type cannot be moved without breaking them.
How to tell it (STAR)
**Situation:** our shared library exposed over [900] public members, so every refactor broke someone. **Task:** make internal changes safe without blocking teams. **Action:** I measured real usage, kept about [80] members public, made the rest internal in a major release, and added the public API analyser so additions are reviewed. **Result:** the next [six] releases had no breaking changes, and services caught up to the latest version within a sprint.
Last updated 30 Oct 2026.