Mert Özen Further With Every Line
Backend Object-Oriented Programming in C# — #03

Encapsulation and Access Modifiers in C#: public, private, protected, internal

Mert Özen Nov 7, 2026 9 min 6 views
Encapsulation and Access Modifiers in C#: public, private, protected, internal

Why a get-only List property protects nothing, keeping invariants inside the object, IReadOnlyList versus AsReadOnly, the six access modifiers and their defaults, private belonging to the type, and the assembly boundary with internal and InternalsVisibleTo.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

In the previous post we said nobody can change the value of a get-only property later. Now let's write an order class: it keeps its items in a get-only List<OrderItem> property and calculates the total from them. Does the code below compile? If it does, what does it print?

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Order order = new Order();
order.Items.Add(new OrderItem("Dune", 45.90m, 2));
Console.WriteLine(order.Total);

order.Items.Clear();
order.Items.Add(new OrderItem("Dune", -100m, 1));
Console.WriteLine(order.Total);

public class Order
{
    public List<OrderItem> Items { get; } = new();
    public decimal Total => Items.Sum(item => item.UnitPrice * item.Quantity);
}

public class OrderItem
{
    public string Title { get; }
    public decimal UnitPrice { get; }
    public int Quantity { get; }

    public OrderItem(string title, decimal unitPrice, int quantity)
    {
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }
}

Why the Get-Only List Did Not Protect Anything

The code compiles without a single warning and prints this:

91.80
-100

We ended up with an order whose total is minus 100. Items { get; } locks only the reference the property points to: you cannot write order.Items = new List<OrderItem>();. But the list that reference points to is completely open; Add, Clear and Remove are in the hands of everyone outside. It is like locking the door and leaving the key under the doormat.

Encapsulation deals with exactly this problem. An object has its own rules, called invariants in the literature: every item in an order has a positive price, a submitted order does not change, the total is the sum of the items. Encapsulation means keeping these rules inside the object and allowing its state to change only through paths that enforce them. Making fields private is the tool, not the goal.

Encapsulation: Keeping the Rules Next to the Data

Let's rewrite the order. The list is now a private field; the outside sees only a read-only view, and changes go through methods that state the intent:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Order order = new Order();
order.AddItem("Dune", 45.90m, 2);
order.AddItem("Clean Code", 39.50m, 1);
Console.WriteLine($"{order.Items.Count} items, total {order.Total}");

try
{
    order.AddItem("Refactoring", -100m, 1);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}

order.Submit();

try
{
    order.AddItem("Refactoring", 52.00m, 1);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

Console.WriteLine($"{order.Items.Count} items, total {order.Total}, submitted: {order.IsSubmitted}");

public class Order
{
    private readonly List<OrderItem> _items = new();

    public IReadOnlyList<OrderItem> Items => _items;
    public bool IsSubmitted { get; private set; }
    public decimal Total => _items.Sum(item => item.UnitPrice * item.Quantity);

    public void AddItem(string title, decimal unitPrice, int quantity)
    {
        if (IsSubmitted)
        {
            throw new InvalidOperationException("A submitted order cannot be changed.");
        }

        if (unitPrice <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(unitPrice), "Unit price must be positive.");
        }

        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
        }

        _items.Add(new OrderItem(title, unitPrice, quantity));
    }

    public void Submit()
    {
        if (_items.Count == 0)
        {
            throw new InvalidOperationException("An empty order cannot be submitted.");
        }

        IsSubmitted = true;
    }
}

public class OrderItem
{
    public string Title { get; }
    public decimal UnitPrice { get; }
    public int Quantity { get; }

    public OrderItem(string title, decimal unitPrice, int quantity)
    {
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }
}
2 items, total 131.30
Unit price must be positive. (Parameter 'unitPrice')
A submitted order cannot be changed.
2 items, total 131.30, submitted: True

The three rules of the order now live inside Order, and there is no ordinary way around them. Because IReadOnlyList<OrderItem> has no Add or Clear, the line order.Items.Add(...) from the first example no longer compiles; the compiler says, with CS1061, "'IReadOnlyList<OrderItem>' does not contain a definition for 'Add'". IsSubmitted being private set matters too: only Submit can say the order is submitted, and it refuses an empty order.

Notice that Order does not say "add an element to the list" to the outside; it says "add an item to the order". The difference looks small, but it is the core of encapsulation. The calling code does not have to know the order's insides; if you decide tomorrow to keep the items in a dictionary instead of a list, not a single line that uses AddItem changes. The well-known name for this is "tell, don't ask": instead of asking an object for its data and making the decision outside, you tell the object what you want and let it decide.

A Read-Only View Is Not a Lock

Returning IReadOnlyList<OrderItem> is not a shield against malicious code. The property still returns the same List<OrderItem> object, only as a narrower type. Someone who casts reaches the list:

Order order = new Order();
order.AddItem("Dune", 45.90m, 2);

List<OrderItem> hacked = (List<OrderItem>)order.Items;
hacked.Clear();
Console.WriteLine(order.Items.Count);

public class Order
{
    private readonly List<OrderItem> _items = new();

    public IReadOnlyList<OrderItem> Items => _items;

    public void AddItem(string title, decimal unitPrice, int quantity)
    {
        _items.Add(new OrderItem(title, unitPrice, quantity));
    }
}

public class OrderItem
{
    public string Title { get; }
    public decimal UnitPrice { get; }
    public int Quantity { get; }

    public OrderItem(string title, decimal unitPrice, int quantity)
    {
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }
}
0

The order is empty. In code written inside the same application this is usually not a problem; whoever writes that cast is breaking the rule on purpose, and code review catches it. But if you are writing a library, or the items go to an external system, hand the list out through a real read-only wrapper. It is a one-line change:

public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.Collections.ObjectModel.ReadOnlyCollection`1[OrderItem]' to type 'System.Collections.Generic.List`1[OrderItem]'.

AsReadOnly() returns a ReadOnlyCollection<T>: underneath it still reads the same list and takes no copy, but it is not a List<T> itself, and its modifying methods throw exceptions. A small wrapper object is created on every read; if the property is read often, you can create it once and keep it in a field.

The Six Access Modifiers

So far we have used public and private. C# has six levels in total, and in a single class they look like this:

Book dune = new Book();
Console.WriteLine(dune.Title);
Console.WriteLine(dune._stock);

class Book // no modifier: internal
{
    public string Title { get; } = "Dune"; // from everywhere
    int _stock = 3; // no modifier: private, only from inside Book
    protected decimal BasePrice { get; set; } // Book and classes derived from it
    internal string ShelfCode { get; set; } = "A-12"; // anywhere in the same assembly
    protected internal string Supplier { get; set; } = ""; // same assembly OR a derived class
    private protected decimal Cost { get; set; } // a derived class in the same assembly
}
Program.cs(3,24): error CS0122: 'Book._stock' is inaccessible due to its protection level
Program.cs(8,9): warning CS0414: The field 'Book._stock' is assigned but its value is never used

Memorize two defaults. If you write nothing on a class member, the member is private. If you write nothing on a type directly inside a namespace, the type is internal. In other words, C# starts from the most closed option, and opening up is your decision. Still, writing the modifier explicitly every time is a common team rule, because the reader does not have to remember the default.

protected and the two-word combinations gain meaning with inheritance; we will use them in the fifth post. protected internal is an "or" relationship and private protected is an "and"; the latter arrived in C# 7.2 and is rarely needed. The warning line is interesting too: the compiler noticed that _stock is assigned but never read. It can know for certain whether a private member is used, because this class is the only place that can see it.

private Belongs to the Type, Not the Object

Access rules work at the type level, not the object level. A Book method can touch the private field of another Book object:

Book shelf = new Book("Dune", 5);
Book window = new Book("Dune", 0);

shelf.MoveStockTo(window, 2);
Console.WriteLine($"shelf: {shelf.Stock}, window: {window.Stock}");

public class Book
{
    private int _stock;

    public string Title { get; }
    public int Stock => _stock;

    public Book(string title, int stock)
    {
        Title = title;
        _stock = stock;
    }

    public void MoveStockTo(Book other, int quantity)
    {
        if (quantity > _stock)
        {
            throw new InvalidOperationException("Not enough stock.");
        }

        _stock -= quantity;
        other._stock += quantity;
    }
}
shelf: 3, window: 2

other._stock compiles because the code is written inside Book. This is not a loophole but a deliberate design: code of the same class already knows the type's rules. It comes in handy when you write equality comparisons, copying, or a transfer between two objects as in this example. The boundary of encapsulation is not "this object" but "this type's code".

internal and the Assembly Boundary

The meaning of internal only shows up once there are several projects. Every project, when compiled, becomes an assembly, that is, a .dll or .exe file. internal means "public inside this assembly, invisible outside". I split Bookstore into a class library and a console application that uses it. The library side:

namespace Bookstore.Core;

public class PriceCalculator
{
    public decimal WithTax(decimal price)
    {
        return price + price * TaxTable.BookRate;
    }
}

internal static class TaxTable
{
    public const decimal BookRate = 0.10m;
}

The console application is connected to the library with a project reference:

using System.Globalization;
using Bookstore.Core;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

PriceCalculator calculator = new PriceCalculator();
Console.WriteLine(calculator.WithTax(45.90m));
Console.WriteLine(TaxTable.BookRate);
Program.cs(8,19): error CS0122: 'TaxTable' is inaccessible due to its protection level

It does not help that BookRate inside TaxTable is public; because the outer type is internal, its members cannot leave the assembly either. The real accessibility of a member is the narrowest of its own and that of the types containing it. The only promise the library makes to the outside is PriceCalculator; how the tax table is stored is the library's internal business and can change tomorrow.

When test projects need to reach internal types, this attribute is added to the library:

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Bookstore.Core.Tests")]

This line opens the internal members to the named assembly. When I added the same attribute with the name Bookstore.App, the console application compiled and printed 50.4900 and 0.10. Use it only for tests; if two assemblies in production code depend on each other's internals, the boundary is drawn in the wrong place.

Common Pitfalls

A property for every field, a set for every property. Making a field private and opening { get; set; } next to it is not encapsulation; it is removing the door and fitting another one just as wide. The question should be "under which rules may this value change from outside?"

Handing out mutable collections as they are. A get-only List<T> property is exactly the bug from the start of this post. Keep a List<T> inside, hand out IReadOnlyList<T> or IReadOnlyCollection<T>, and wrap it with AsReadOnly() if needed.

Treating internal as "slightly public". In a single-project application there is no practical difference between internal and public; the difference appears when you write a library. In libraries make internal the default choice; every type you open to the outside on purpose is a promise.

Scattering rules across service classes. If the rule "items cannot be added to a submitted order" is written separately in an OrderService, a CartController and a background job, one of them will be forgotten. A rule belongs where its data is. Spreading rules out leads to anemic models that carry data but cannot protect anything.

A Small Experiment

★☆☆ Add a RemoveItem(string title) method to Order. It should throw an exception on a submitted order and return false for a title that is not there.

★★☆ When AddItem is called a second time with the same title, increase the quantity of the existing item instead of adding a new one. Since Quantity on OrderItem is get-only, how will you do it: replace the old item with a new one, or add a method to OrderItem? Think about the difference between the two approaches from an encapsulation point of view.

★★★ Return Items from a ReadOnlyCollection<OrderItem> field created once in the constructor, instead of _items.AsReadOnly(). Then add an item with AddItem and check whether an Items reference you took earlier sees the new item. The result will tell you whether the wrapper is a copy or a view.

In this post we sealed the rules inside the object. Some data and behavior, however, belong to no object but to the type itself: how many books have been created in total, a helper that validates an ISBN, stateless operations like Math.Round. In the next post we cover static members, static classes and extension methods, which look as if they add methods to existing types.