📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

What is encapsulation, and how do you apply it well in C#? Show the difference between a class that exposes its data and one that protects its invariants.

Encapsulation as protecting invariants: hiding state, exposing behaviour, and the C# tools that support it.

Junior Theory Beginner access-modifiers encapsulation invariants oop properties

🎯 Key points

  • Encapsulation means an object owns its state and changes it only through rule-enforcing behaviour
  • Keep fields private and expose read access through get-only or private-set properties
  • Expose collections as `IReadOnlyList<T>` and provide intent-named methods to change them
  • Validate in constructors or factories so objects are never created invalid
  • Public getters and setters for every field is not encapsulation
  • Tell, don't ask: call `account.Withdraw(x)` instead of reading and writing fields
  • Encapsulation lets internals change without breaking callers
  • Data-only DTOs need less encapsulation than domain objects with rules

⏱ 30-second answer

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.

πŸ—£ Full answer (senior)

How I would answer this in an interview

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.

1. A class that exposes its data

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:

  • Any code can put the object in an invalid state (negative balance, balance not matching transactions, withdrawals on a frozen account).
  • Business rules are duplicated across controllers, jobs and services β€” and drift apart.
  • Changing the representation (e.g. computing the balance from transactions) breaks every caller.
  • Thread safety and auditing can't be enforced centrally.

2. A class that protects its invariants

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:

  • Invariants (balance β‰₯ βˆ’overdraft, no withdrawals when frozen, balance consistent with transactions) are enforced in one place and can't be bypassed.
  • Callers express intent (Withdraw, Freeze), not mechanics.
  • The internal representation can change without breaking callers.
  • Unit tests target the rules directly.

3. Tools in C#

  • Access modifiers: private fields; private set/init on properties; internal for assembly-only types.
  • Constructors / factory methods that only create valid objects.
  • Read-only views of collections (IReadOnlyList<T> with AsReadOnly()), never the mutable list.
  • Value objects (Money, EmailAddress as records) that validate themselves, so primitives can't carry invalid values.
  • Methods named after operations ("Tell, Don't Ask") rather than setters.
  • sealed to stop subclasses bypassing invariants.

4. Where pure data is fine

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.

5. A production example

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.

πŸ” What the interviewer is checking

A pillar-of-OOP question that quickly shows whether the candidate thinks in invariants.

  • Green flags: talks about protecting invariants, private state with behaviour methods, read-only collection exposure, tell-don't-ask, and why getters/setters alone are not enough.
  • Red flags: defines encapsulation only as "making fields private" or exposes mutable lists.

πŸ“š Learn in depth

1. Invariants: the heart of encapsulation

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.Start
  • "A shipped order can't change its lines"

Encapsulation 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).

2. Information hiding vs encapsulation

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.

3. Getters/setters are not encapsulation

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.

4. Collections are the common leak

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:

  • Expose IReadOnlyList<T> via AsReadOnly() (a wrapper), or an ImmutableArray<T> snapshot.
  • Provide AddLine, RemoveLine methods enforcing rules.
  • EF Core supports backing fields for navigations (builder.Navigation(o => o.Lines).HasField("_lines")), so persistence doesn't require public setters.

5. Encapsulation and frameworks

  • EF Core can materialise entities through private constructors and private setters, and use backing fields β€” no need for public mutability.
  • System.Text.Json can deserialise via constructors ([JsonConstructor]) and init properties; but domain entities usually shouldn't be serialised directly anyway (use DTOs).
  • Model binding needs settable properties β€” another reason to bind to DTOs, not entities.

6. Encapsulation at larger scales

The same principle applies to:

  • Assemblies/projects: internal types, exposing only a small public surface; InternalsVisibleTo for tests sparingly.
  • Modules in a modular monolith: other modules call a public API, never each other's tables.
  • Services: a service owns its database; others go through its API (no shared database writes).

In every case, the owner enforces the rules and can change internals freely.

Try it yourself

  1. Take an entity in your code with public setters and list every place that sets them; find duplicated or missing rules.
  2. Refactor it with private setters and intention-revealing methods, and write unit tests for each invariant.
  3. Map it with EF Core using backing fields and a private parameterless constructor.
  4. Try casting an exposed IReadOnlyList<T> back to List<T>, then switch to AsReadOnly().

πŸ’» Code

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>

⚠ Common mistakes

1. Equating encapsulation with "private fields + public properties"

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.

2. Exposing mutable collections

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.

3. Validating one property at a time

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.

4. Putting business rules in services that manipulate data classes

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.

5. Over-encapsulating DTOs

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.

6. Leaking internals through getters

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.

7. Making everything public "for tests"

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.

8. Allowing subclasses to bypass invariants

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.

πŸ› Senior insight: trade-offs

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.

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate uses encapsulation and abstraction interchangeably Why: Checks they can separate hiding state from simplifying a concept
    How to answer

    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.

  2. When: Candidate exposes collections as read-only views Why: Probes read-only versus truly immutable collections
    How to answer

    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.

↩ Usually asked after

More in Core OOP

Last updated 30 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me