C# Access Modifiers: Public Contracts and Private State
Choose which operations callers may use and which details stay inside the implementation. Learn how access levels differ from static ownership and use a small public interface to protect valid state.
What you will understand
- Choose a public interface instead of exposing every field and helper.
- Use private fields and helpers behind guarded operations.
- Distinguish class-member defaults from top-level type defaults.
- Explain internal as an assembly boundary rather than a namespace boundary.
- Recognize protected access through a small inheritance preview.
- Distinguish protected internal OR from private protected AND.
- Separate accessibility from static or instance ownership.
- Verify permitted workflows and intentionally rejected access.
Design the operations callers are supposed to use
Access modifiers decide which source code may use a type or member. They are part of a type’s contract: callers should see the operations they need, while implementation details remain behind the boundary. This lesson continues the inventory model from constructors, where valid starting state still needs protection afterward.
Our public surface contains creation, a guarded sale, a stock query and a description. The stored name and count are private. Main can request a sale but cannot assign an arbitrary count. That distinction makes the operation’s rule meaningful because supported callers must pass through it.
Accessibility does not implement the rule by itself. A public TrySell method still needs its quantity guard, and a private field can still be changed incorrectly inside its own class. Access modifiers reduce accidental misuse; they are not a substitute for correct implementation, user authentication or authorization in a web application.
Program.cs · Expose operations, keep the stored count private
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
True
Pen: 3 in stock
Main can construct the item and call its public operations. Only InventoryItem’s implementation directly updates private stock. public controls access to the operation; it does not automatically validate its arguments. The guard inside TrySell provides that behavior.
Program.cs · A caller cannot write a private field
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Compilation fails: InventoryItem.stock is inaccessible due to its protection level.The attempted direct write fails before execution. Keep the intended operation public rather than exposing the field to make the error disappear. Private access is a source-code boundary around implementation details, not encryption or user authorization.
Public is a supported entry point; private is implementation detail
A public member can be used wherever its containing type is accessible. A private member belongs to the implementation boundary of its declaring type. In these examples, Main calls public TrySell, and TrySell calls private CanSell. The caller does not need the helper just because the implementation needs it.
Keeping CanSell private avoids promising that external callers can depend on that particular helper. You can combine, rename or reorganize internal checks later while preserving the intended operation. A public query can report current stock without allowing direct writes to the stored count.
Ordinary class members with no access modifier default to private, while a class declared at namespace scope defaults to internal. These defaults differ, so read the declaration’s context. The Widget example intentionally omits modifiers and fails when Main tries to call its private Read method. Use explicit declarations when they make the boundary easier to review.
Program.cs · The owning class can call its own private helper
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
False
5
TrySell can call CanSell because both are declared in InventoryItem. Main does not need that helper in its supported interface. A helper can change later while callers continue to use the same public operation. A refused oversell leaves the field unchanged.
Program.cs · Ordinary class members default to private
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Compilation fails: Widget.Read is inaccessible due to its protection level.Read has no access modifier, so this ordinary class member is private. Top-level classes have a different default: internal. Explicit modifiers make the intended boundary easier to review; do not assume every omitted modifier means public.
Internal refers to a compiled assembly, not a folder or namespace
An assembly is the compiled unit, such as a .dll or executable, produced from source files. internal normally grants access to code in that same assembly. Two source files can share an assembly; two projects can use the same namespace while compiling into different assemblies. Neither a folder name nor a namespace alone defines the boundary.
The Tools.Status example is internal and sits in another namespace, but Main can use it because the entire editor is compiled together. This proves same-assembly access only. Copying text into a second file without changing the compilation boundary would still not test access from a different assembly.
A public member inside an internal type does not automatically make that type a public library API. Accessibility is constrained by the containing type. Public methods also cannot expose less accessible parameter or return types in an otherwise externally visible signature, as the deliberate inconsistent-accessibility example later demonstrates. Friend-assembly grants exist but are outside this beginner lesson.
Program.cs · Internal means this assembly, not this namespace
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Same assembly
Program and Tools.Status are compiled into the same assembly even though their namespace locations differ. The internal type and method are accessible here. This single-file runner cannot prove rejection from another assembly; internal access across separate projects requires an assembly-boundary check.
Protected is for an inheritance boundary
protected allows a member to be used inside its declaring class and by suitable code in derived classes. The small StockReport : ReportBase example previews the inheritance syntax: StockReport derives from ReportBase and exposes a public Render method that uses a protected heading helper.
Main can call Render, but it cannot directly call the protected Heading method. Being in the same source file or holding a derived object does not make an unrelated caller part of the derived class’s implementation. Protected instance access through references also has receiver restrictions; later inheritance lessons develop those rules in more detail.
Do not replace private with protected automatically in case inheritance is needed someday. Protected members are an API for derived implementations and can become a long-term design commitment. For a class with no intended inheritance extension point, private implementation details are often clearer.
Program.cs · A derived class can use a protected base member
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Inventory report
The colon means StockReport derives from ReportBase. Its Render method can use the inherited protected Heading helper, while Main uses the public Render operation. This is a small preview of inheritance, not overriding or polymorphism. protected is not a general grant to every class in the assembly.
Program.cs · An unrelated caller does not gain protected access
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Compilation fails: ReportBase.Heading is inaccessible due to its protection level.Main is not code inside ReportBase or its derived StockReport implementation. It cannot call Heading directly, even though the object is a StockReport and all the source is in one file. Use the supported public Render operation.
Read protected internal as OR and private protected as AND
protected internal grants a same-assembly route OR a derived-class route. An unrelated class in the same assembly can therefore use such a member. Derived code in another assembly can use the protected route subject to the ordinary protected access rules. The example uses a static member to keep receiver details out of the first comparison.
private protected narrows the boundary to the declaring or derived type AND the same assembly. An unrelated Main does not gain access merely by compiling alongside the declaration. The intentionally failing example contrasts it with protected internal so the similar names do not hide their different meanings.
These modifiers are reference tools, not requirements for every model. Our inventory class needs only a small public interface and private implementation. C# also supports file for top-level types restricted to the source file; it is separate from these member modifiers and cannot be combined with an access modifier. You can revisit broader library and inheritance boundaries when your design actually needs them.
Program.cs · Protected internal includes unrelated same-assembly code
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Same-assembly caller allowed
protected internal combines two access routes with OR: same assembly, or derived-class access subject to protected rules. Main is unrelated but satisfies the same-assembly route. The name does not mean only derived classes inside this assembly.
Program.cs · Private protected requires a derived relationship as well
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Compilation fails: ReportRules.Label is inaccessible due to its protection level.private protected uses the same-assembly AND same-or-derived-type boundary. Main being in the same assembly is insufficient. These combined modifiers are mainly useful when designing inheritance boundaries; you need not add them to a simple inventory class.
Accessibility and static ownership answer different questions
public or private answers who can use the declaration. static or instance answers whether it belongs to the type or needs an object receiver. A public static helper and a private static field can coexist, just as public instance methods can use private instance fields.
You cannot repair a missing receiver by making everything public, and you cannot repair an inaccessible field by making it static. Identify the actual issue before changing a modifier. An access diagnostic may correctly reveal that the caller is reaching past the supported interface.
Also review the types appearing in a public signature. The ReportService example returns an internal HiddenReport from a public method on a public class and fails compilation. Choose a contract with a coherent audience: make the service internal if it is application-only, or expose an intentional public result abstraction when building a public library.
Program.cs · Ownership and accessibility are separate decisions
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
8
Double is public and static: accessible through this type and called without an instance. Factor is private and static: shared type state hidden from Main. Neither static nor public guarantees immutability or validation. Inputs are small fixed integers in this example.
Program.cs · A public API cannot expose a less accessible return type
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Compilation fails: the return type HiddenReport is less accessible than ReportService.Create.ReportService is public, but HiddenReport is internal. An external caller could see the method contract without being allowed to use its returned type. Make the API assembly-internal when that is its intended audience, or deliberately expose an appropriate public result type. Do not make every implementation type public reflexively.
Verify both permitted behavior and rejected access
A boundary change can leave normal output identical while preventing unsupported code from compiling. The lab starter deliberately prints the correct result even though it exposes too much. Reviewing only the reference output would miss that design problem.
Test the allowed path through constructor, TrySell and query methods. Then attempt a direct field write and a helper call from Main; those should fail after the repair. Intentional compiler-failure examples are part of the lesson rather than evidence that the runner is broken.
For internal and combined modifiers, remember the limit of this single-assembly editor. It demonstrates same-assembly access, inheritance previews and unrelated-caller failures, but not a compiled external library consumer. A real library must also test a separate assembly. Private source access is not encrypted storage or protection from reflection or a hostile process.
Guided repair: close the direct-write bypass
The model refuses invalid sales, yet Main can set stock directly to a negative number. Close that unsupported write path and change the caller to use the meaningful sale operation.
Program.cs · A public field bypasses an otherwise valid sale rule
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Pen: -10 in stock
The constructor and TrySell checks cannot stop a caller from assigning an unrestricted public field afterward. The program compiles and exposes an invalid count. This is a boundary problem rather than a missing guard inside TrySell.
Repair checkpoints
- Change the stock field from public to private.
- Remove Main’s direct pen.stock assignment.
- Request pen.TrySell(10) instead of writing an arbitrary negative count.
- Verify the request is refused and stock remains five.
- Keep queries public so callers can observe state without bypassing the operation.
Reveal the repair and explanation
Program.cs · Use a supported operation instead of arbitrary assignment
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Oversell accepted: False
Pen: 5 in stock
stock is private again. Main requests a meaningful operation and the model refuses an oversell without changing state. Fixing the caller is part of the repair: merely adding private while leaving the direct assignment would correctly produce a compiler error, not a working public workflow.
Independent lab: reduce the public surface
Review an inventory class whose output is correct but whose API exposes stored stock and a validation helper. Reduce its public surface while preserving all supported caller behavior.
Acceptance checklist
- Keep name and stock private instance fields.
- Make CanSell a private helper used only by the class implementation.
- Keep the constructor, TrySell, GetStock and Describe public.
- Do not change constructor validation or sale refusal rules.
- Main must use only supported operations and never assign stock directly.
- Preserve Sale: True, Oversell: False and Pen: 3 in stock for the reference sequence.
- Verify direct field access and an external CanSell call fail compilation.
Program.cs · Public-surface review starter
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Sale: True
Oversell: False
Pen: 3 in stock
The reference program behaves correctly, but stock and CanSell are exposed unnecessarily. Review declarations as well as output. The task is to reduce the supported public surface while preserving constructor, sale, query and reporting behavior.
Show hints
- The starter needs declaration changes, not a different sale formula.
- private does not stop TrySell calling another method in the same class.
- Read-only observation can use GetStock without exposing field assignment.
Reveal the reference solution
Program.cs · Restricted public-surface reference solution
Predict the result, edit Program.cs, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Sale: True
Oversell: False
Pen: 3 in stock
Both fields and the helper are private. The constructor, TrySell, GetStock and Describe remain accessible to the caller. The output intentionally matches the starter: the improvement is which unsupported actions the compiler now rejects, not an altered report.
Changed-input checks
- A sale of zero, a negative number or more than stock is refused and leaves state unchanged.
- Selling all five succeeds, and a later sale of one is refused.
- Main can still construct an item, sell and print its description.
- Adding pen.stock = -1 in Main must produce an access diagnostic.
- Adding pen.CanSell(1) in Main must produce an access diagnostic.
- Keep the same supported output after narrowing access.
Recap: who may use this declaration?
Choose an intentional audience for each declaration, then verify both permitted operations and rejected access.
public- Accessible wherever the containing type and signature permit.
private- Implementation access within the declaring type; callers use supported operations.
internal- Normally accessible to code in the same compiled assembly.
protected- Declaring and derived-type access, subject to protected receiver rules.
protected internal- Same assembly OR protected derived-type access.
private protected- Declaring or derived type AND same assembly.
file (top-level types)- Restrict a top-level type to its source file; separate from member access levels.
static versus instance- Ownership and receiver choice, independent of who may access the member.
Frequently asked questions
Does private mean a value cannot change?
No. The declaring type can still change its own mutable fields. private controls access, while the implementation must preserve its rules.
Does internal mean the same namespace?
No. It normally means the same compiled assembly. Namespaces organize type names and do not define assembly access.
Are omitted class member modifiers public?
No. Ordinary class members default to private. Top-level classes default to internal, so context matters.
Is protected internal more restrictive than protected?
No. It adds a same-assembly access route with OR. private protected instead requires the same assembly and the declaring or derived-type relationship.
Should I make a helper public to test it?
Usually test behavior through the supported public interface. Exposing a helper solely for a convenient call expands the API contract unnecessarily.
Is public the same as accessible from the internet?
No. It is a C# source accessibility level. Network routes, authentication and user permissions are separate application concerns.
Can you choose the right access boundary?
Distinguish public contracts, private implementation, assembly access and inheritance boundaries. Read the explanation after checking your answers.
Next step and references
Access modifiers define who may use a declaration. Keep supported operations accessible, hide stored state and helpers, and separate access decisions from static ownership and application authorization.
Next is C# Properties and Encapsulation. You will expose deliberate read and write behavior with accessors while maintaining valid object state.
Official references
Original SovranCode explanations, examples and exercises. Technical details were checked against these references.
Last updated: .