Encapsulation as protecting invariants: hiding state, exposing behaviour, and the C# tools that support it.
Encapsulation means an object owns its state and only lets it change through behaviour that keeps it valid. In C# you keep fields private, expose read access through properties (often get-only or private set), and offer methods like Withdraw(amount) that check rules before changing state, instead of a public setter on Balance. Collections are exposed as IReadOnlyList<T> with Add... methods. The benefit is that invariants ("balance never goes below the overdraft limit") are enforced in one place, so callers cannot put the object into an invalid state, and the internals can change without breaking callers.
Encapsulation means an object owns its data and the rules about that data: callers interact through a small set of operations, and the object guarantees its invariants β the conditions that must always be true. Access modifiers are the mechanism; protecting invariants is the point. I'd show a class that exposes its data and one that protects it.
public class BankAccount
{
public decimal Balance { get; set; }
public List<Transaction> Transactions { get; set; } = new();
public bool IsFrozen { get; set; }
}
// Rules live in callers - copied, forgotten, or inconsistent
account.Balance -= 500m; // overdraft? frozen? audit entry?
account.Transactions.Add(new Transaction(-500m));
Problems:
public sealed class BankAccount
{
private readonly List<Transaction> _transactions = [];
public BankAccount(AccountId id, decimal overdraftLimit)
{
ArgumentOutOfRangeException.ThrowIfNegative(overdraftLimit);
Id = id;
OverdraftLimit = overdraftLimit;
}
public AccountId Id { get; }
public decimal OverdraftLimit { get; }
public decimal Balance { get; private set; }
public bool IsFrozen { get; private set; }
public IReadOnlyList<Transaction> Transactions => _transactions.AsReadOnly();
public void Withdraw(decimal amount, DateTimeOffset at)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
if (IsFrozen) throw new InvalidOperationException("Account is frozen.");
if (Balance - amount < -OverdraftLimit) throw new InsufficientFundsException(Id, amount, Balance);
Balance -= amount;
_transactions.Add(Transaction.Debit(amount, at)); // balance and history change together
}
public void Freeze() => IsFrozen = true;
}
Now:
Withdraw, Freeze), not mechanics.private fields; private set/init on properties; internal for assembly-only types.IReadOnlyList<T> with AsReadOnly()), never the mutable list.Money, EmailAddress as records) that validate themselves, so primitives can't carry invalid values.sealed to stop subclasses bypassing invariants.Not everything needs encapsulation. DTOs, API request/response models and configuration options are data carriers β public init properties are fine there. Encapsulation matters where there are rules: domain entities and aggregates.
In a subscription billing system, Subscription.Status and Subscription.EndDate had public setters and were updated from five places. A support tool set Status = Cancelled without setting EndDate, and a nightly job kept billing those customers. Moving to Cancel(DateOnly effectiveDate, CancellationReason reason) that set both fields and raised a domain event, with private setters, made that state impossible and gave us a single place to add auditing.
A pillar-of-OOP question that quickly shows whether the candidate thinks in invariants.
An invariant is a condition that's true after construction and after every public operation. Examples:
Order.Total == Lines.Sum(l => l.Amount)Booking.End > Booking.StartEncapsulation is the technique that makes invariants enforceable: if only the object's own methods can change its state, and each method preserves the invariants, then the invariants hold for the object's whole life. This is the basis of Bertrand Meyer's Design by Contract (preconditions, postconditions, invariants).
David Parnas's information hiding (1972) is about hiding design decisions likely to change behind a stable interface β e.g. whether the balance is stored or computed. Encapsulation bundles data with the behaviour that operates on it. Good C# design applies both: bundle behaviour with data, and hide representation choices.
A class with private fields and public get; set; for every field is still exposing its data β just with extra syntax. The test is: can a caller put this object into an invalid state? If yes, it isn't encapsulated. Properties with logic in setters help a little, but validating one field at a time can't enforce rules that span fields (e.g. start/end dates) β that needs methods.
public List<OrderLine> Lines { get; } = new(); // getter-only, but callers can Add/Clear
Even IReadOnlyList<T> returning the underlying List<T> can be cast back. Patterns:
IReadOnlyList<T> via AsReadOnly() (a wrapper), or an ImmutableArray<T> snapshot.AddLine, RemoveLine methods enforcing rules.builder.Navigation(o => o.Lines).HasField("_lines")), so persistence doesn't require public setters.[JsonConstructor]) and init properties; but domain entities usually shouldn't be serialised directly anyway (use DTOs).The same principle applies to:
internal types, exposing only a small public surface; InternalsVisibleTo for tests sparingly.In every case, the owner enforces the rules and can change internals freely.
IReadOnlyList<T> back to List<T>, then switch to AsReadOnly().public sealed class LoyaltyAccount
{
private readonly List<PointsEntry> _history = [];
public LoyaltyAccount(string memberId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(memberId);
MemberId = memberId;
}
public string MemberId { get; }
public int Points { get; private set; } // read publicly, change only here
public IReadOnlyList<PointsEntry> History => _history; // read-only view
public void Earn(int points, string reason)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(points);
Points += points;
_history.Add(new PointsEntry(points, reason, DateTimeOffset.UtcNow));
}
public void Redeem(int points, string reason)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(points);
if (points > Points)
throw new InvalidOperationException($"Cannot redeem {points}; only {Points} available.");
Points -= points;
_history.Add(new PointsEntry(-points, reason, DateTimeOffset.UtcNow));
}
}
public sealed record PointsEntry(int Change, string Reason, DateTimeOffset At);
// account.Points = 1_000_000; // CS0272: setter is private
// account.History.Clear(); // not available on IReadOnlyList<T>
What happens: every field gets a public getter and setter.
Why it is wrong: callers can still set any combination of values; invariants aren't protected.
Do instead: expose operations that maintain invariants; keep setters private or init.
What happens: public List<Line> Lines { get; }.
Why it is wrong: callers can add, remove or clear items, bypassing rules and totals.
Do instead: return read-only views and provide methods for changes.
What happens: setters validate individually, but cross-field rules (start before end) are unchecked. Why it is wrong: objects can still reach invalid combinations. Do instead: change related fields together in one method that checks the whole rule.
What happens: OrderService checks status and updates order fields; other services do the same differently.
Why it is wrong: rules are duplicated and inconsistent (anemic domain model).
Do instead: move rules into the entity where the data lives.
What happens: request/response models get private setters and methods.
Why it is wrong: DTOs are data carriers; this complicates serialisation for no benefit.
Do instead: use records with init properties for DTOs; encapsulate domain types.
What happens: a getter returns an internal mutable object (a configuration dictionary, a mutable value object). Why it is wrong: callers mutate internal state through the returned reference. Do instead: return copies, immutable types or read-only interfaces.
What happens: private members are made public to assert on them.
Why it is wrong: breaks encapsulation and couples tests to implementation.
Do instead: test through public behaviour; use internal + InternalsVisibleTo sparingly.
What happens: protected setters or virtual methods that skip validation. Why it is wrong: derived classes can violate the base class's rules. Do instead: seal domain classes or keep state private with protected operations that enforce rules.
Seniors judge encapsulation by whether invalid states are impossible, not by how many fields are private.
| Question | Good sign |
|---|---|
| Can a caller create an invalid object? | no: constructor or factory validates |
| Can a caller break an invariant later? | no: only intent methods change state |
| Can internals change without breaking callers? | yes |
| Is there duplicated validation in services? | no |
| Is the type's API named in business terms? | Redeem, not SetPoints |
They also balance it: DTOs and records that only carry data across boundaries do not need behaviour; encapsulation matters most for domain objects with rules.
Abstraction means showing only what a caller needs and hiding how it is done: an IPaymentGateway.ChargeAsync(amount) hides HTTP calls, retries and provider formats. Encapsulation protects an object's state and invariants; abstraction simplifies how others think about and use something. In C# you express abstractions with interfaces, abstract classes and well-named public methods. A good abstraction is named in the caller's terms, small, stable, and has more than one real reason to exist. A leaky abstraction forces callers to know the hidden details (provider-specific exceptions, SQL in a repository method name). A premature abstraction is an interface invented for one implementation that never changes, adding indirection without value.
Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.
Last updated 30 Oct 2026.