Last Updated: May 17, 2026
Encapsulation is the rule that a class controls its own state, and properties paired with access modifiers are the tools that make the rule enforceable. The OOP section introduced the basic shapes ({ get; set; }, public, private). This lesson digs into the patterns that show up once you start designing real classes: asymmetric accessors, init-only setters, required properties, full properties with validation, computed properties, the two combined access modifiers, and how to expose collections without handing callers the keys.
A property's two accessors don't have to share the same visibility. You can declare the property itself at one level and then tighten one of the accessors to something stricter. The most common version is { get; private set; }, which makes the property readable from anywhere and writable only from inside the declaring class.
From the outside, Total and ItemCount look read-only. From inside Cart, methods like AddItem and Clear can update them freely. The class is the only place where the two fields move, which means they can never drift out of sync. If Total were a plain public field, any caller could write cart.Total = -100m and corrupt the invariant.
The rule that gates this is small but specific: one of the accessors has to match the declared property visibility, and the other can be stricter. public T Prop { get; private set; } is fine because get matches public. public T Prop { protected get; private set; } is not fine because both accessors are stricter than the property itself. The compiler reports CS0273 when it catches that.
The protected set variant is the inheritance cousin of private set. Subclasses can write to the property; outside callers still can't.
DiscountedOrder is a subclass, so it can assign Total through the protected set. Main is not a subclass, so the same assignment fails. The setter visibility tracks "who is allowed to drive this property's state."
Where the setter actually lives in terms of reachable scope is worth visualizing, because the asymmetry confuses people the first time they see it:
The cyan node is anyone calling the class from outside: they hit the public getter and bounce off the private setter. The green node is internal code, where both reads and writes succeed. The class is the gatekeeper between the two regions.
A private set keeps the setter alive for internal use, but the property can still be mutated for the entire object lifetime by any method in the class. Sometimes that's what you want. Sometimes you want the property to accept exactly one write, during construction, and then refuse all subsequent assignments. That's what the init accessor does. It arrived in C# 9.
init behaves like a setter while the object is being constructed (inside a constructor body, inside an object initializer, inside with expressions on records) and behaves like a missing setter afterward.
The object initializer block is still part of construction, so each of the four assignments runs through the init accessor and lands in the backing field. The moment the closing brace of the initializer runs, the object is "done" and init stops accepting writes. Try to assign Price afterward and the compiler reports CS8852: "Init-only property... can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor."
Compare this with what you'd get from the older two forms:
{ get; set; } accepts the initializer assignments and accepts every assignment afterward. Mutable forever.{ get; } rejects the initializer assignments. You can only set the value through a constructor, which means a class with ten get-only properties forces a ten-parameter constructor on every caller.{ get; init; } accepts the initializer assignments and rejects everything afterward.init is the modern default for data classes because it keeps the call site clean (new Product { Name = "...", Price = ... }) and freezes the object after construction. It pairs especially well with records, which the Modern C# Features section covers in depth.
init accessors can carry validation just like set accessors can. The body runs for every assignment that happens during construction:
The init accessor takes the same implicit value parameter as set does, runs the same kind of validation, and assigns to the same backing field. The only difference is the time window during which it can be invoked.
Cost: A trivial init is the same cost as a trivial set: the JIT inlines both down to a field write. The compile-time check that blocks post-construction assignment has zero runtime cost.
init solves "freeze after construction." It doesn't solve "force the caller to set this value at all." With just init, callers can write new Product() and end up with an empty Name, an empty Category, and a price of 0m. The init accessor doesn't fire if nobody assigns to the property.
C# 11 added the required modifier to close that gap. A required property must be assigned somewhere during construction, either by the constructor or by the object initializer. The compiler tracks each required property at every call site and refuses to build code that doesn't assign all of them.
Name and Price are required; Category and StockCount are not. Writing new Product { Price = 79.99m } fails with CS9035 because Name is not set. The check happens at compile time at every call site, which means the rule survives whether the caller is in the same project or in a different assembly that references this one.
required pairs naturally with init, but the combination is not mandatory. You can mark a { get; set; } property as required too, in which case the property must be assigned during construction but stays mutable afterward.
The required part is checked once, at construction. After that, the setter behaves like any other public setter. Most code prefers required with init because the combination produces the strongest guarantee: every instance has a value, and the value never changes.
A subtle wrinkle is how required interacts with constructors. If a class has a constructor that assigns the required property itself, callers don't have to repeat the assignment in the initializer. You signal that with the [SetsRequiredMembers] attribute on the constructor:
[SetsRequiredMembers] is the compiler's way of trusting a constructor to honor the contract. Without it, even a constructor that clearly assigns both Name and Price would still force callers to repeat the assignments. With it, the parameterized constructor counts as "all required members set."
Auto-properties and init-only properties cover the cases where the property is just "store this value." Once the property needs to enforce a rule on every write, the auto form runs out. The full form, with an explicit backing field, becomes necessary.
A common case is a discount percentage that has to stay between 0 and 100:
The backing field percent holds the actual value. The set accessor checks value before assigning. When the check fails, the throw runs before percent is touched, so the previous valid value is still inside the object. That last detail is the reason for the order: validate first, store second. Reversing the order would briefly admit invalid state into the object before the throw, which is a bug waiting to happen if any other code reads through the backing field directly during the same call.
Validation isn't the only reason to drop the auto form. Other common ones:
A trimmed-and-lowercased email is a good example of the normalization pattern:
The caller passed a string with leading and trailing whitespace and mixed case. The init accessor stored a clean version. Every read of Email afterward returns the normalized form, with no chance of seeing the raw input. This is encapsulation doing real work: the class owns what "a valid email" looks like in storage, and callers don't have to remember to call .Trim() themselves.
A small note on naming. The convention in modern C# is to call the backing field email (lowercase) or _email (underscore-prefixed). The two styles are equally common. Pick one per codebase and stay consistent. The C# style guide from Microsoft uses _camelCase for private fields; the .NET runtime source uses s_camelCase for statics. Either is fine inside a single project as long as it's uniform.
Cost: A full property with validation is roughly the cost of a method call (which the JIT can inline if the body is small). The branch in the setter is essentially free for normal values. The cost only becomes visible if validation does real work like a regex match, a database call, or a string allocation, and even then it only fires on writes, not reads.
A property doesn't have to store anything. If the value is a function of other state on the object, the getter can compute it and skip the backing field entirely. These are called computed properties (or calculated properties), and the expression-bodied syntax makes them especially clean:
Subtotal and IsBulk don't have backing fields. Every read recomputes from Quantity and UnitPrice. The class doesn't need to remember the subtotal because it can always derive it, and that means the subtotal can never get out of sync with the inputs.
The expression-bodied form (=> Quantity * UnitPrice) is shorthand for a get-only property with a single-expression getter. The two forms below are equivalent:
The expression form is shorter and reads as if you're stating an algebraic identity (Subtotal IS Quantity times UnitPrice), which matches how most readers think about derived values. Use it whenever the body is a single expression. Switch to a block body if you need local variables, multiple statements, or any control flow.
The catch with computed properties is that they re-run on every read. For O(1) expressions like multiplication, comparison, or array indexing, the cost is invisible: the JIT inlines them and the result looks like a single instruction. For O(n) expressions like summing a list, scanning a string, or filtering a collection, the cost adds up.
For a normal e-commerce cart with a handful of items, recomputing Subtotal on every read is invisible. For a hot loop that reads Subtotal once per item in the same cart, you've turned an O(n) computation into an O(n^2) one. The fix when this becomes a real problem is to cache the result in a backing field and recompute only when one of the inputs changes:
Now Subtotal is a field read, and the sum runs once when LineTotals is assigned. The trade-off is the usual one: extra state to keep in sync against extra speed on reads. Don't reach for caching until a profiler tells you the computation actually shows up.
A useful rule of thumb: compute when the body is O(1) and obviously cheap. Store when the body is O(n) and gets read often. Move expensive work into a method named RecalculateSubtotal() when the cost is high enough that callers should see "this is doing work" at the call site. A property carries an implicit promise that reading it is cheap; a method named Recalculate... does not.
The _Access Modifiers_ lesson covered the standard four access modifiers. Encapsulation patterns at scale sometimes need finer control, and that's where the two combined modifiers come in: protected internal and private protected. Both involve a protected part and an assembly-scoped part, but they combine those in opposite ways.
protected internal is the union. A member marked protected internal is visible if either condition holds: same assembly OR derived class anywhere. That makes it broader than either protected or internal on its own.
private protected is the intersection. A member marked private protected is visible only if both conditions hold: same assembly AND derived class. That makes it narrower than either keyword alone.
The names sound backwards until you read them as logical operators: protected internal is "protected OR internal," and private protected is "private to the assembly AND protected to subclasses." Once you've seen the table once, it tends to stick:
| Modifier | Same class | Derived class<br/>(same assembly) | Other class<br/>(same assembly) | Derived class<br/>(other assembly) | Other class<br/>(other assembly) |
|---|---|---|---|---|---|
public | Yes | Yes | Yes | Yes | Yes |
protected internal | Yes | Yes | Yes | Yes | No |
internal | Yes | Yes | Yes | No | No |
protected | Yes | Yes | No | Yes | No |
private protected | Yes | Yes | No | No | No |
private | Yes | No | No | No | No |
Reading top to bottom, the visibility narrows. public reaches everywhere. private reaches nowhere except the declaring class. The other four sit between those poles, each picking up or dropping one of the two questions the compiler asks: "are we in the same assembly?" and "are we in a subclass?"
The same idea as a flowchart:
The two questions at each split (same assembly?, subclass?) are the only inputs to the decision. Every modifier is just a particular pattern of "yes/no" answers to those two questions.
A concrete example uses two assemblies. Assume Shop.Core.dll contains a base class and Shop.App.dll references it and defines a subclass:
SeasonalDiscount is a subclass of Discount but lives in a different assembly. MinimumOrder is protected internal, so the subclass relationship is enough to let SeasonalDiscount write to it. MaxStackable is private protected, which requires both the subclass relationship and the same assembly; the second condition fails, so the access is rejected.
The everyday rule of thumb when designing a base class for inheritance:
protected when subclasses anywhere need the member (public-facing libraries).private protected when subclasses in your own codebase need the member but you don't want third-party subclasses pulling on it.protected internal only when you genuinely want both same-assembly code and subclasses to share the member. The cases are rare in practice.Cost: Access modifiers are compile-time only. The runtime treats public and private protected members identically once the code is compiled. The cost is paid in compile errors, not in performance.
Properties that hold collections are where encapsulation most often goes wrong. The trap looks like this:
Items is technically a get-only property: no setter, no init. But the property returns a List<string>, and List<string> is mutable. Callers can't replace the whole list, but they can mutate the one the cart hands them. The "read-only property" gives no protection at all.
The fix is to return a read-only view of the list, not the list itself. The simplest version uses IReadOnlyList<T>, which is an interface that exposes only the read members:
The internal field items is still a List<string>. The public property declares its type as IReadOnlyList<string>, which exposes only Count, the indexer, and IEnumerable<string>. There's no Add, no Remove, no Clear. Callers can read the items in any order they like; they can't change the collection.
There's one gotcha worth knowing about, because it bites people who think IReadOnlyList<T> is bulletproof. The runtime object is still a List<string>. A determined caller can cast back to the concrete type:
IReadOnlyList<T> is a contract for the static type, not for the runtime object. If you hand out a reference to a mutable list and a caller casts it back, the cast succeeds and the mutation goes through. The compiler doesn't catch this because the cast is structurally valid; the runtime doesn't catch it because the object really is a List<string>.
There are two ways to plug the hole. The first is ReadOnlyCollection<T> from System.Collections.ObjectModel. It wraps the list in a new object that doesn't expose the underlying storage:
ReadOnlyCollection<T> is a different runtime type from List<T>. The is List<string> check returns false, so the cast fails and the would-be sneaky Add never runs. The wrapper still reflects the underlying list (so additions through AddItem appear in the view), but external callers can't mutate it directly.
The second way is to copy the list on every read. This gives stronger isolation (callers can't even see future additions through the same reference) at the cost of a fresh allocation per access. Use it only when isolation matters more than performance:
ImmutableArray<T> and ImmutableList<T> from System.Collections.Immutable are a more thorough answer: collections that are physically immutable, where mutation methods return a new instance instead of changing the old one. The _Immutable Collections_ lesson covers them in detail. For now, the practical advice is short:
IReadOnlyList<T> (or IReadOnlyCollection<T>, IReadOnlyDictionary<TKey, TValue>) for the public property type. It covers the common case where callers won't try to cast around the contract.ReadOnlyCollection<T> when you're handing the collection to code you don't fully trust, or when you want the contract to be defended even against deliberate misuse.System.Collections.Immutable when you need true immutability across threads or want value-equality semantics.The three "the value is set once" tools look similar from the outside, and they overlap enough that beginners often pick whichever one they remember first. They have meaningful differences once you start designing real classes:
| Feature | readonly field | { get; } (get-only property) | { get; init; } (init-only property) |
|---|---|---|---|
| Assignable in constructor | Yes | Yes | Yes |
| Assignable in field/property initializer | Yes | Yes | Yes |
Assignable in object initializer (new T { X = ... }) | No | No | Yes |
| Assignable after construction | No | No | No |
| Can have validation logic in its assignment | No | Yes (in constructor only) | Yes (in init accessor) |
| Can be exposed as part of a public API | Discouraged | Yes | Yes |
| Survives binary versioning (replaceable with logic later) | No | Yes | Yes |
The decision tree boils down to two questions.
Question 1: do callers ever need to assign this from outside the class?
If no, a readonly field is the lightest weight option. It's a single keyword, the compiler enforces "assigned only in the constructor or initializer," and there's no accessor method involved at runtime. The catch is that readonly fields shouldn't be public, because making one public freezes its name and type into your public API the same way any public field does, and you lose the ability to grow validation later. Keep readonly fields private and use them as backing storage.
If yes, you need a property. Move to question 2.
Question 2: do callers assign through a constructor parameter or through an object initializer?
If the property is always assigned through a constructor (and the constructor parameter list is short), { get; } is the simplest answer. It compiles to a readonly backing field, accepts assignment in the constructor body, and rejects everything else.
If the property is one of many that callers might or might not want to set, { get; init; } is the better answer. It supports the object initializer syntax (new Order { Customer = ..., Total = ... }), which scales much better than a 12-parameter constructor. After the initializer closes, the property is just as immutable as the get-only form. Pair with required if the property must be set; leave required off if it has a sensible default.
A worked example shows all three in the same class, each pulling its own weight:
placedAt is a private readonly field because no caller needs to know about it; the constructor sets it, the Receipt method reads it. OrderId is a get-only property because it's part of the public API and is always supplied through the constructor. Customer and Total are init-only properties because the call site benefits from object initializer syntax, and Customer is required so the compiler rejects callers who forget to set it.
That mix is what a typical immutable e-commerce data class looks like once you've internalized the trade-offs. Each property is set exactly once, exactly when it makes sense to set it, with exactly the syntax that fits.
{ get; private set; } and { get; protected set; } let outside callers read while keeping writes inside the class or its subclasses. The pattern is how stateful classes expose data without giving up control of when the data changes.init accessors (C# 9+) accept writes during construction, including in object initializers, and freeze afterward. They give you object initializer syntax and immutability at the same time.required (C# 11) forces callers to assign a property during construction, checked at every call site. Combined with init, it produces "must be set, can never change."protected internal is the union (same assembly OR subclass anywhere). private protected is the intersection (same assembly AND subclass). Read the names as logical operators and the rule sticks.List<T> through IReadOnlyList<T> prevents most mutation by static type but allows runtime downcasts. Use ReadOnlyCollection<T> (or immutable collections from System.Collections.Immutable) for stronger protection.readonly field for private storage, { get; } for public values always set via constructor, and { get; init; } for public values set via object initializer. They overlap in intent but differ in where the assignment can happen.