Skip to main content
ANVISoftware Solutions
Lesson 25 of 62Intermediate18 min

Encapsulation

By the end of this lesson

Hide internal state so a type cannot be put into an invalid condition from outside.

Encapsulation is often taught as "make your fields private". That is a mechanism, not the point, and taken alone it produces classes full of private fields wrapped in public setters that guard nothing.

The actual idea: an object should only be changeable in ways that leave it correct. You decide which changes are legitimate, you expose one method for each of them, and you close every other route in. Callers then cannot create a broken object even by accident, because the language will not let them.

An order with nothing protected, and four ways to break it
C#
public class LeakyOrder
{
    public string Reference { get; set; } = "";
    public List<OrderLine> Lines { get; set; } = new List<OrderLine>();
    public decimal Total { get; set; }
    public bool IsSubmitted { get; set; }
}

LeakyOrder order = new LeakyOrder { Reference = "SO-4471" };

order.Total = 5000m;              // a total with no lines behind it
order.IsSubmitted = true;         // submitted with nothing in it
order.Lines = null!;              // no lines at all, and the type said there would be
order.Reference = "";             // an order nobody can look up
  • Every one of those four lines compiles and runs. None of them is a bug in LeakyOrder; the bug is that LeakyOrder has no opinion about anything.
  • Total is the clearest failure. It is stored separately from the lines, so the two can disagree, and nothing in the type keeps them in step.
  • The rules still exist — an order does need lines before submission. They now live in the heads of whoever writes the calling code, and are enforced by nobody.

The cost of that design shows up later, and not where you would expect. A support ticket says an order was submitted with a total of zero. You search for the code that submitted it and find eleven places that set IsSubmitted. Each is slightly different. One forgot to recalculate the total.

Encapsulation is what reduces eleven suspects to one.

The same order, with the rules written down once
C#
public enum OrderStatus { Draft, Submitted, Cancelled }

public class Order
{
    private readonly List<OrderLine> _lines = new List<OrderLine>();

    public string Reference { get; }
    public OrderStatus Status { get; private set; }

    // A read-only view of the real list. Callers can look; they cannot add.
    public IReadOnlyList<OrderLine> Lines => _lines;

    // Derived, never stored, so it cannot drift away from the lines.
    public decimal Total
    {
        get
        {
            decimal total = 0m;

            foreach (OrderLine line in _lines)
            {
                total += line.LineTotal;
            }

            return total;
        }
    }

    public Order(string reference)
    {
        if (string.IsNullOrWhiteSpace(reference))
        {
            throw new ArgumentException("An order needs a reference.", nameof(reference));
        }

        Reference = reference;
        Status = OrderStatus.Draft;
    }

    public void AddLine(OrderLine line)
    {
        if (Status != OrderStatus.Draft)
        {
            throw new InvalidOperationException("Lines can only be added while an order is a draft.");
        }

        _lines.Add(line);
    }

    public void Submit()
    {
        if (_lines.Count == 0)
        {
            throw new InvalidOperationException("An order needs at least one line before it can be submitted.");
        }

        Status = OrderStatus.Submitted;
    }
}
  • OrderStatus is an enum: a type whose value must be one of a fixed set of named options. It rules out the misspelled status strings that a plain string would allow.
  • _lines is private and readonly. Private means outside code cannot reach it; readonly means Order itself cannot swap it for a different list, so it is never null.
  • Lines exposes IReadOnlyList<OrderLine>. This is a view onto the same list, not a copy — a caller sees additions made through AddLine but has no Add method of their own.
  • Total is computed from the lines on every read. The total that disagreed with the lines is now not merely unlikely; it is unrepresentable.
  • Status has a private setter, so the only paths to Submitted run through Submit(), where the rule lives. One suspect instead of eleven.

A practical way to decide what to expose:

  • Start from the operations, not the data. What can legitimately happen to this thing? Those become the public methods.
  • Anything a caller only reads becomes a get-only or private-set property.
  • Anything that can be worked out from other values becomes a computed property rather than stored state.
  • Internal storage stays private, and readonly wherever it is assigned once.
  • When you expose a collection, expose a read-only view unless callers genuinely need to modify it directly.

Summary

  • Encapsulation is about protecting rules, not about hiding data for its own sake
  • Expose one method per legitimate operation and close every other route to the state
  • Values that can be derived should be computed, so they cannot contradict what they came from
  • A public property returning your private list leaks the state anyway — expose a read-only view
  • Types with no invariants, such as data transfer objects, do not need any of this

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Think about it

A colleague argues that the Order class above is over-engineered: the team can agree to always recalculate Total after changing Lines, and a code review will catch anyone who forgets.

What is the difference between a rule enforced by agreement and a rule enforced by the type?

Show solution

An agreement has to be known, remembered and applied by every person who touches the code, including people who join in two years and never hear about it. A rule in the type is applied by the compiler, every time, at no ongoing cost.

There is also an asymmetry in how failures show up. A forgotten recalculation produces a wrong number that looks plausible, so it may survive review and reach a customer. A rule in the type produces a compile error, which cannot be missed.

Your colleague is not wrong that it is more code. The judgement is whether this type has rules worth that cost. For an order total, in a system that bills people, it usually is. For a class holding three values for a report, it usually is not.

Try it yourself

Try it yourself

Add a Cancel() method to Order with these rules: a draft or submitted order can be cancelled, a cancelled order cannot be cancelled again, and cancelling must not remove the lines.

Then add a RemoveLine(OrderLine line) method that only works while the order is a draft.

Show solution

Both methods follow the same shape: check the current status, refuse with a message that says why, then make the change. That repetition is not accidental — it is what "one place per legitimate operation" looks like.

InvalidOperationException is the right exception here rather than ArgumentException. Nothing is wrong with the argument; the object is in the wrong state for the request. Choosing the accurate exception type makes logs readable later.

C#
public void Cancel()
{
    if (Status == OrderStatus.Cancelled)
    {
        throw new InvalidOperationException($"Order {Reference} is already cancelled.");
    }

    Status = OrderStatus.Cancelled;
}

public void RemoveLine(OrderLine line)
{
    if (Status != OrderStatus.Draft)
    {
        throw new InvalidOperationException("Lines can only be removed while an order is a draft.");
    }

    _lines.Remove(line);
}

Saved in this browser only.