Private fields were the training wheels. Real encapsulation is about protecting the rules your object can never be allowed to break.
Here's a bug that shows up constantly in real codebases, and it has nothing to do with forgetting a private keyword:
public class BankAccount
{
public decimal Balance { get; set; }
public List<Transaction> Transactions { get; set; } = new();
}
// Somewhere else in a 200,000-line codebase...
account.Balance -= 500m; // withdrawal recorded here
// ...but nobody added a Transaction. Now Balance and Transactions disagree forever.
account.Transactions.Clear(); // someone "resets" the account for a demo
// ...and now the audit trail is gone. Nothing stopped them — Transactions was a public List<T>.
Every field here is technically accessible only through auto-properties, which looks encapsulated. But nothing stops Balance from drifting out of sync with Transactions, and nothing stops any caller anywhere in the codebase from clearing the transaction history outright. The fields are private in name, but the object's rules are not protected at all.
In this lesson, you'll go past "make fields private" and learn what encapsulation is actually protecting: invariants, valid state transitions, and the shape of the data you expose — the difference between a domain model that defends itself and an anemic bag of properties that doesn't.
You already know that encapsulation means hiding internal state behind properties and methods. In real applications, the meaningful unit of encapsulation isn't a field — it's an invariant: a rule about the object's state that must hold true at every moment the object exists, no matter which method was called or in what order.
An invariant is a promise, not a data type. Examples:
Balance always equals the sum of all Transactions."Order can only move from Pending to Shipped, never backward."DateRange's Start is never later than its End."ShoppingCart never contains a line item with quantity ≤ 0."Real encapsulation means: there is no way to construct or mutate the object into a state that violates one of these rules. Not "there's a validation method you could call" — no way, period, enforced by the type itself.
{ get; set; }An anemic domain model is a set of classes that are really just data containers — properties with public getters and setters and no behavior — while all the actual logic (validation, calculations, state transitions) lives in separate "service" classes that manipulate the data from outside.
// Anemic model — just data, no rules
public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public List<OrderLine> Lines { get; set; } = new();
public decimal Total { get; set; }
}
// All the actual rules live somewhere else, disconnected from the data:
public class OrderService
{
public void Ship(Order order)
{
// Nothing stops a caller from skipping this and setting order.Status directly.
if (order.Status != OrderStatus.Pending)
throw new InvalidOperationException("Only pending orders can ship.");
order.Status = OrderStatus.Shipped;
}
}
This compiles fine and looks organized — but it's an illusion of structure. Any code anywhere in the solution can do order.Status = OrderStatus.Shipped; directly, bypassing every rule in OrderService. The rules exist only by convention, not by enforcement. As a codebase grows past a few thousand lines and a few dozen contributors, "by convention" rules get violated — not out of malice, but because nobody can hold every convention in their head at once.
A rich domain model puts the rules inside the type that owns the data, so the rules are enforced no matter who calls the object or from where:
// Rich model — data and rules travel together
public class Order
{
private readonly List<OrderLine> _lines = [];
public int Id { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Pending;
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(l => l.Quantity * l.UnitPrice);
public Order(int id) => Id = id;
public void AddLine(string sku, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Cannot modify an order after it has shipped.");
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
_lines.Add(new OrderLine(sku, quantity, unitPrice));
}
public void Ship()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Only pending orders can ship.");
if (_lines.Count == 0)
throw new InvalidOperationException("Cannot ship an order with no line items.");
Status = OrderStatus.Shipped;
}
}
Now there is no code path — anywhere in the solution, written by anyone, today or in three years — that can produce an Order with a negative-quantity line, or ship an order twice, or add a line after shipping. The rule isn't "please remember to check this" — the object refuses to let it happen.
required members / factory methods that can't succeed with bad input{ get; set; } lets any caller assign any value, bypassing rulesList<T> from a property lets callers .Add(), .Remove(), or .Clear() without the owning object knowingIReadOnlyList<T> / IReadOnlyCollection<T>, and provide explicit methods for any mutation you actually want to allowBalance and Transactions) are each independently settableThis is the single most common encapsulation gap in real codebases: exposing a mutable collection as if it were read-only.
// Looks encapsulated. Isn't.
public class ShoppingCart
{
public List<CartItem> Items { get; } = new();
}
var cart = new ShoppingCart();
cart.Items.Add(new CartItem("SKU-1", 2)); // fine, intentional
cart.Items.Clear(); // also fine, as far as the compiler knows — but was it intended?
// Encapsulated: exposes a read-only view, mutation only through explicit methods
public class ShoppingCart
{
private readonly List<CartItem> _items = [];
public IReadOnlyList<CartItem> Items => _items;
public void AddItem(CartItem item)
{
if (item.Quantity <= 0)
throw new ArgumentException("Quantity must be positive.", nameof(item));
_items.Add(item);
}
public void RemoveItem(string sku) => _items.RemoveAll(i => i.Sku == sku);
}
var cart2 = new ShoppingCart();
cart2.AddItem(new CartItem("SKU-1", 2));
// cart2.Items.Add(...); // compiler error — IReadOnlyList<T> has no Add()
// cart2.Items.Clear(); // compiler error — the caller can no longer wipe the cart by accident
Code → Meaning → Result: IReadOnlyList<T> doesn't create an immutable copy — it's the same underlying list, just viewed through an interface that has no mutating members. The compiler now refuses any external code that tries to mutate the collection directly, which forces every mutation through AddItem/RemoveItem, where the invariant (positive quantity) is actually checked.
Let's fix the BankAccount from the hook. The core design decision: don't store Balance as an independent, settable field — derive it from the transaction history, so it's mathematically impossible for the two to disagree.
public sealed record Transaction(DateTime OccurredAt, decimal Amount, string Description);
public class BankAccount
{
private readonly List<Transaction> _transactions = [];
public string AccountNumber { get; }
public IReadOnlyList<Transaction> Transactions => _transactions;
// Balance is never stored — it's always computed, so it can never drift.
public decimal Balance => _transactions.Sum(t => t.Amount);
public BankAccount(string accountNumber)
{
if (string.IsNullOrWhiteSpace(accountNumber))
throw new ArgumentException("Account number is required.", nameof(accountNumber));
AccountNumber = accountNumber;
}
public void Deposit(decimal amount, string description)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive.");
_transactions.Add(new Transaction(DateTime.UtcNow, amount, description));
}
public void Withdraw(decimal amount, string description)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive.");
if (amount > Balance)
throw new InvalidOperationException("Insufficient funds.");
_transactions.Add(new Transaction(DateTime.UtcNow, -amount, description));
}
}
// Usage
var account = new BankAccount("ACC-001");
account.Deposit(1000m, "Initial deposit");
account.Withdraw(250m, "Rent");
Console.WriteLine(account.Balance); // 750 — always correct, always in sync
// account.Transactions.Clear(); // compiler error — audit trail can't be wiped
// account.Withdraw(10_000m, "Uh oh"); // throws InvalidOperationException — no overdraft possible
Why this holds up in production:
set for Balance — it can never be wrong, because it's not stored, it's computed.Withdraw), enforced for every caller, forever — not scattered across every service that touches accounts.Transactions) cannot be tampered with from outside, which matters enormously in a domain like banking where auditability is a legal requirement, not a nicety.IReadOnlyList<T> Actually Buys YouIReadOnlyList<T> is an interface with no mutating members; a List<T> already satisfies it without any conversion or allocation_items as IReadOnlyList<CartItem> costs nothing at runtime — same object, narrower viewList<CartItem> (e.g. via reflection, or a careless (List<CartItem>)cart.Items if the actual type were public) can still mutate it_items.ToList().AsReadOnly(), or use ImmutableList<T> from System.Collections.ImmutableNot automatically. A class where every field is private but every property has a public setter that accepts any value is encapsulated in name only — the invariants aren't protected, just relocated behind a slightly longer syntax. Encapsulation is about what states the object can be in, not which keyword guards the field.
No, and conflating them is a common source of anemic models. A DTO (Data Transfer Object) exists to move data across a boundary — an HTTP response body, a message queue payload, a database row. DTOs are supposed to be plain data with public getters/setters; they have no business rules to protect, because their whole job is to be a transparent shape for serialization. A domain model exists to represent a business concept and enforce its rules, so it needs the encapsulation techniques in this lesson. The mistake is using the same class for both jobs — mapping your rich Order domain object directly to and from JSON usually forces you to add public setters "just for deserialization," which quietly destroys the invariants you built. Keep a separate DTO (or use record types with constructors bound at the API boundary) and map between the two explicitly.
Putting the rule "an order must have at least one line to ship" in a stateless OrderValidator class, called from one controller action. Any other code path that touches Order.Status directly skips the check entirely.
Put the rule inside Order.Ship() so it's enforced no matter who calls it, from a controller, a background job, or a test.
public List<OrderLine> Lines { get; set; } = new(); // whole list can be replaced or cleared
If a framework (like a model binder or an ORM) genuinely needs a settable collection, keep that concern on a separate DTO, not on the domain type.
Storing both Balance and Transactions independently invites drift the moment one code path updates one but not the other.
Compute Balance from Transactions every time. It's slightly more CPU work, and it's impossible for the two to disagree — usually the right trade-off unless profiling proves otherwise.
Assuming a record is automatically safe because its properties are init-only. A record with a mutable List<T> property is still an anemic, tamperable object — immutability of the reference doesn't protect the referenced collection.
public record Order(int Id, List<OrderLine> Lines); // Lines is still fully mutable from outside
public record Order(int Id, IReadOnlyList<OrderLine> Lines); // better — no external mutation path
record and move on.IReadOnlyList<T> / IReadOnlyCollection<T>, and provide explicit, rule-checking methods for the mutations you intend to allow.You've seen how encapsulation is about protecting rules, not just hiding fields. Let's check that it stuck.
1. A class has all private fields, exposed only through auto-properties with public getters and setters. Is this class properly encapsulated?
Correct: B
Why B is correct: Public setters with no validation let any caller assign any value, which means the object's invariants (its rules about valid state) are not actually protected — just relocated behind a slightly longer syntax.
Why A is incorrect: Private fields alone only hide storage location; they say nothing about whether the values assigned through public setters are validated.
Why C is incorrect: Auto-properties are fine when there's genuinely no rule to enforce (like a simple DTO) — the problem is only when rules exist but aren't checked.
Why D is incorrect: Implementing an interface has nothing to do with whether an object's internal state is protected from invalid mutation.
Reinforcement: Encapsulation is measured by what invalid states are unreachable, not by which keyword wraps a field.
2. Why does exposing a property as List<T> (instead of IReadOnlyList<T>) undermine encapsulation, even if the property itself has no setter?
Correct: C
Why C is correct: A get-only property returning a List<T> still hands out the actual mutable object. There's no setter on the property, but the list itself has mutating methods the property's lack of a setter does nothing to prevent.
Why A is incorrect: The absence of a setter only blocks reassigning the property to a whole new list — it does not block mutating the existing one.
Why B is incorrect: The performance difference is negligible and isn't the reason this matters; the concern is about safety, not speed.
Why D is incorrect: Both List<T> and IReadOnlyList<T> work fine with LINQ.
Reinforcement: Use IReadOnlyList<T> (or a similar read-only interface) to remove the mutating members from the caller's view entirely.
3. What is the key difference between an anemic domain model and a rich domain model?
Correct: C
Why C is correct: The defining difference is where the rules live and whether they can be bypassed. Anemic models rely on external services to enforce logic, which any other code path can skip; rich models bake the rules into the type itself.
Why A is incorrect: Both records and classes can be anemic or rich — the distinction is about where behavior lives, not the keyword used to declare the type.
Why B is incorrect: Property count is unrelated; an anemic model can have many properties and still have zero enforced rules.
Why D is incorrect: They describe meaningfully different designs with different failure modes, as shown in the Order example in this lesson.
Reinforcement: Anemic vs. rich is about whether the object can defend its own invariants, not about syntax.
4. In the BankAccount example, why is Balance implemented as public decimal Balance => _transactions.Sum(t => t.Amount); instead of as a stored field updated by Deposit/Withdraw?
Correct: B
Why B is correct: If Balance were a separately stored field, some code path could update it without updating Transactions (or vice versa), causing drift. Deriving it removes that possibility entirely, at the cost of recomputing it on each access.
Why A is incorrect: Computed properties recalculate on every access, so they are typically slower than a cached field, not faster — the benefit here is correctness, not speed.
Why C is incorrect: C# fully supports decimal fields; that isn't a constraint at play here.
Why D is incorrect: This is a deliberate correctness decision, not a stylistic one — it directly closes the drift bug shown in the lesson's opening example.
Reinforcement: When two pieces of data must always agree, storing only one of them and deriving the other is a strong way to guarantee it.
You now understand encapsulation as invariant protection, not just field hiding — the foundation everything else in this module builds on.
dotnetmadeeasy.com — Learn C# and .NET, the right way.