Access Control Fundamentals
Myth: Access modifiers are just about hiding code. Reality: They define contracts that prevent breaking changes and guide proper usage.
Many developers default to making everything public because it seems simpler. Then six months later, they can't refactor internal logic without breaking consuming code. A tightly controlled public API lets you evolve implementation freely while protecting callers from instability.
You'll learn all five access levels (public, private, protected, internal, protected internal), when to use each one, how they interact with inheritance and assemblies, and common gotchas that create accidental exposure. By the end, you'll design classes with clear boundaries that make your codebase maintainable and evolution-friendly.
Public and Private: The Foundation
Public members are accessible from anywhere. Use public for your type's contract with the outside world: methods, properties, and events that consumers depend on. Once public, changing signatures becomes a breaking change requiring major version bumps.
Private members are only accessible within the containing type. This is your implementation details zone. Private fields, helper methods, and internal state stay hidden. You can change private members freely without affecting any external code.
public class BankAccount
{
// Private: implementation details
private decimal balance;
private List<Transaction> transactionHistory;
// Public: contract with consumers
public string AccountNumber { get; }
public string Owner { get; }
public BankAccount(string accountNumber, string owner)
{
AccountNumber = accountNumber;
Owner = owner;
balance = 0;
transactionHistory = new List<Transaction>();
}
// Public: part of the API
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive", nameof(amount));
balance += amount;
RecordTransaction("Deposit", amount);
}
public bool Withdraw(decimal amount)
{
if (amount > balance)
return false;
balance -= amount;
RecordTransaction("Withdrawal", amount);
return true;
}
public decimal GetBalance() => balance;
// Private: internal implementation
private void RecordTransaction(string type, decimal amount)
{
transactionHistory.Add(new Transaction(type, amount, DateTime.Now));
}
}
record Transaction(string Type, decimal Amount, DateTime Date);
The balance field is private, so consumers can't bypass Deposit and Withdraw logic to manipulate it directly. Public methods form the safe interface. RecordTransaction is private because it's an implementation detail that might change (switching to event sourcing, for example) without affecting callers.
Protected: Inheritance Boundaries
Protected members are accessible within the declaring class and any derived classes. This creates extensibility points for subclasses while keeping implementation hidden from unrelated code. Use protected for methods that subclasses override or fields they need to access.
Protected is less restrictive than private but more restrictive than public. It says "this is part of the inheritance contract, not the public API." Think of it as the interface between a base class and its children.
public abstract class Vehicle
{
// Private: only this class uses it
private string vin;
// Protected: derived classes can access
protected double fuelLevel;
protected double fuelCapacity;
// Public: API for all consumers
public string Make { get; }
public string Model { get; }
protected Vehicle(string make, string model, string vin, double fuelCapacity)
{
Make = make;
Model = model;
this.vin = vin;
this.fuelCapacity = fuelCapacity;
fuelLevel = fuelCapacity; // Start with full tank
}
// Protected virtual: subclasses can override
protected virtual double CalculateFuelConsumption(double distance)
{
return distance * 0.08; // Default: 8L per 100km
}
// Public: uses protected methods internally
public bool Drive(double distance)
{
var fuelNeeded = CalculateFuelConsumption(distance);
if (fuelNeeded > fuelLevel)
return false;
fuelLevel -= fuelNeeded;
OnDriving(distance);
return true;
}
// Protected: extensibility hook for subclasses
protected virtual void OnDriving(double distance)
{
// Base implementation does nothing
// Subclasses can override for custom behavior
}
public void Refuel() => fuelLevel = fuelCapacity;
}
public class ElectricCar : Vehicle
{
public ElectricCar(string make, string model, string vin)
: base(make, model, vin, 100) // Electric "fuel" is battery
{
}
// Override protected method with electric-specific logic
protected override double CalculateFuelConsumption(double distance)
{
return distance * 0.15; // kWh consumption
}
protected override void OnDriving(double distance)
{
Console.WriteLine($"Electric motor engaged for {distance}km");
// Access protected field from base class
Console.WriteLine($"Battery level: {fuelLevel:F1}kWh");
}
}
The derived ElectricCar accesses fuelLevel and overrides CalculateFuelConsumption because they're protected. It can't access vin because that's private. This selective exposure lets base classes control exactly what subclasses can touch.
Internal: Assembly Boundaries
Internal members are accessible from any code within the same assembly but hidden from external assemblies. This is perfect for implementation types that your library uses internally but consumers don't need to see. Internal classes reduce your public API surface while enabling code sharing within your project.
Combine internal types with a small public facade to create libraries with clean interfaces. Your public types delegate to internal workers, giving you flexibility to refactor internals without breaking consumers.
// Public API: what consumers see
public class PaymentService
{
private readonly CreditCardValidator validator;
private readonly PaymentGateway gateway;
public PaymentService()
{
validator = new CreditCardValidator();
gateway = new PaymentGateway();
}
public PaymentResult ProcessPayment(string cardNumber, decimal amount)
{
if (!validator.Validate(cardNumber))
return PaymentResult.Invalid("Invalid card number");
return gateway.Charge(cardNumber, amount);
}
}
// Internal: implementation hidden from consumers
internal class CreditCardValidator
{
internal bool Validate(string cardNumber)
{
if (string.IsNullOrWhiteSpace(cardNumber))
return false;
// Luhn algorithm check
return CheckLuhn(cardNumber);
}
private bool CheckLuhn(string number)
{
// Implementation details
return number.Length >= 13;
}
}
internal class PaymentGateway
{
internal PaymentResult Charge(string cardNumber, decimal amount)
{
// Gateway integration logic
Console.WriteLine($"Charging {amount:C} to card ending in {cardNumber[^4..]}");
return PaymentResult.Success();
}
}
public class PaymentResult
{
public bool IsSuccess { get; }
public string Message { get; }
private PaymentResult(bool success, string message)
{
IsSuccess = success;
Message = message;
}
public static PaymentResult Success() => new(true, "Payment processed");
public static PaymentResult Invalid(string reason) => new(false, reason);
}
Consumers see only PaymentService and PaymentResult. The internal validators and gateways are free to change implementation without versioning concerns. This separation keeps your public API stable while letting internal code evolve.
Protected Internal and Private Protected
Protected internal combines both: accessible from the same assembly OR from derived classes in any assembly. This is the most permissive of the protected/internal combinations. Use it when you want both inheritance extensibility and internal access.
Private protected (C# 7.2+) requires both conditions: accessible only from derived classes AND only within the same assembly. This restricts inheritance to your own codebase while preventing external assemblies from subclassing and accessing these members.
public class BaseWidget
{
// Protected internal: accessible from derived classes anywhere
// OR from any code in this assembly
protected internal virtual void OnInitialize()
{
Console.WriteLine("Base initialization");
}
// Private protected: only derived classes in this assembly
private protected void InternalSetup()
{
Console.WriteLine("Internal setup logic");
}
// Regular protected: derived classes in any assembly
protected virtual void OnRender()
{
Console.WriteLine("Base rendering");
}
}
// In the same assembly
public class CustomWidget : BaseWidget
{
protected override void OnInitialize()
{
base.OnInitialize();
InternalSetup(); // OK: derived class in same assembly
Console.WriteLine("Custom initialization");
}
}
// Helper in same assembly (not derived)
internal class WidgetFactory
{
public BaseWidget Create()
{
var widget = new BaseWidget();
widget.OnInitialize(); // OK: protected internal allows same-assembly access
// widget.InternalSetup(); // ERROR: not a derived class
return widget;
}
}
Protected internal is useful for framework code where you want internal test helpers and external inheritance. Private protected tightens this by preventing external assemblies from inheriting and accessing the member, keeping inheritance within your library boundaries.
Avoiding Common Mistakes
Public fields instead of properties: Never expose public fields. They can't add validation, raise change notifications, or be overridden. Always use properties with backing fields. Refactoring a public field to a property is a breaking change (reflection and serialization behavior differs).
Forgetting effective accessibility: A public method in an internal class is effectively internal. The containing type's accessibility caps member accessibility. When designing APIs, start with the type's access level and work inward.
Overusing protected: Protected creates coupling between base and derived classes. Every protected member is a commitment that subclasses might depend on. If you're not actively supporting inheritance, keep members private. Composition often beats protected inheritance hierarchies.
Choosing the Right Access Level
Start with the most restrictive access (private) and relax only when needed. This principle of least privilege keeps your codebase flexible. When tempted to make something public, ask: "Will external code directly use this, or should it go through a facade?"
Choose public when: The member is part of your type's contract that consumers depend on. Documentation becomes a promise. Version carefully because changes break consuming code. Use for stable, well-designed APIs.
Choose private when: The member is pure implementation that might change frequently. Helper methods, caching fields, and algorithm details should be private. This gives you maximum refactoring freedom.
Choose protected when: You're designing for inheritance and derived classes need access to implementation or hooks. Use sparingly because it couples base and derived types. Document what subclasses can safely override or access.
Choose internal when: You're building reusable components within a library but don't want to expose implementation types publicly. Keep your public API small and focused. Internal types can change without versioning concerns.
Try It Yourself
Build a simple app demonstrating different access modifiers and their effects on visibility.
Steps
- Scaffold project:
dotnet new console -n AccessDemo
- Navigate:
cd AccessDemo
- Open Program.cs and add the code below
- Compile and run:
dotnet run
var counter = new Counter();
counter.Increment();
counter.Increment();
counter.Increment();
Console.WriteLine($"Count: {counter.GetCount()}");
Console.WriteLine($"Is Valid: {counter.IsValid()}");
// Try uncommenting these to see compiler errors:
// counter.count = 100; // ERROR: 'count' is inaccessible
// counter.Validate(); // ERROR: 'Validate' is inaccessible
public class Counter
{
// Private: hidden from external code
private int count;
// Public: part of the API
public void Increment()
{
count++;
Validate();
}
public int GetCount() => count;
public bool IsValid()
{
return count >= 0;
}
// Private helper
private void Validate()
{
if (count < 0)
throw new InvalidOperationException("Count cannot be negative");
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output
Count: 3
Is Valid: True