BrandGhost
Encapsulation in C#: Preserve Invariants Through Public Operations

Encapsulation in C#: Preserve Invariants Through Public Operations

Encapsulation in C# is more useful than the instruction to make fields private. It is the boundary between an object's internal representation and the operations callers are allowed to perform. That boundary matters most when several values must remain consistent together.

Consider a quantity range. A minimum and maximum can each be valid integers while forming an invalid range. If the minimum exceeds the maximum, the model no longer represents its intended meaning. The interesting problem is not storing the numbers. It is preserving the relationship whenever the object is constructed or changed.

Example scope: The three independent console examples target .NET 10 and have not been compiled or executed. Predicted behavior is reasoned from the code and current Microsoft documentation. None is a concurrency or persistence implementation.

Encapsulation in C# begins with a stated invariant

An invariant is a condition your model intends to keep true. For this article's range, both bounds must be nonnegative and the minimum must not exceed the maximum. These are example-specific rules. C# does not automatically apply them to integers or properties.

Microsoft's encapsulation overview explains limiting access to members. That access control gives you a place to enforce the rules. However, it does not tell you which rules your application needs.

Start by naming the valid state in plain language. If the sentence requires two values, reviewing each property independently may miss the actual condition. “Maximum is nonnegative” is not the same promise as “maximum is at least minimum.”

This is where public operations become useful. An operation can accept the values that belong in one transition, validate them together, and then publish the new state through the object's normal queries. Callers express an intention rather than a sequence of unrelated assignments.

There is a tradeoff. Public setters are simple for unrestricted data. A named operation makes a relationship visible but adds behavior and an error contract. Neither style is universally correct; the invariant explains why this example needs more than unrestricted setters.

Construction establishes the initial boundary

Microsoft notes that constructors enable valid instances when created. The constructor is therefore one place to establish the range's initial rule. It is not enough to validate later changes while allowing invalid initial values.

The first example validates the proposed pair before assigning either backing field. The same check serves both construction and the public transition, so the two entry paths describe the same valid state.

using System;

namespace RangeExample;

internal static class Program
{
    private static void Main()
    {
        var range = new QuantityRange(2, 8);
        range.ChangeBounds(3, 10);

        Console.WriteLine($"{range.Minimum} to {range.Maximum}");
    }
}

public sealed class QuantityRange
{
    private int _minimum;
    private int _maximum;

    public QuantityRange(int minimum, int maximum)
    {
        ValidateBounds(minimum, maximum);
        _minimum = minimum;
        _maximum = maximum;
    }

    public int Minimum => _minimum;
    public int Maximum => _maximum;

    public void ChangeBounds(int minimum, int maximum)
    {
        ValidateBounds(minimum, maximum);

        _minimum = minimum;
        _maximum = maximum;
    }

    private static void ValidateBounds(int minimum, int maximum)
    {
        if (minimum < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(minimum));
        }

        if (maximum < minimum)
        {
            throw new ArgumentOutOfRangeException(
                nameof(maximum),
                "Maximum must be at least minimum.");
        }
    }
}

The public properties allow observation without public assignment. ChangeBounds represents one logical transition. In this sequential example, validation completes before either field changes, so a validation exception leaves the old pair intact.

That last statement has a limited scope. Two field assignments are not a general concurrency guarantee. The example has no locking or transactional storage. It explains the order of validation and mutation, not safe simultaneous access from multiple threads.

Avoid pretending that an object is “valid” in every possible sense because it has a constructor. The example validates its own range rule. It does not know whether a particular customer may choose the range, whether it matches a stored record, or whether some external process has changed another value.

Those other responsibilities may belong at different boundaries. Keeping them separate prevents a small model from becoming a dumping ground for unrelated checks while still preserving the checks necessary for its own meaning.

Properties are behavior, not just decorated fields

Microsoft's property guidance describes accessors as code and lists validation as one of their uses. A property can therefore reject an invalid assignment rather than simply store it.

However, property-level validation and operation-level validation are not interchangeable. If callers change two related values separately, the intermediate pair may violate the intended rule. A named operation can accept the whole proposed pair.

For example, moving a range from 2..8 to 10..12 involves more than deciding which setter to invoke first. The public operation describes the desired new range directly. The object handles its own representation rather than asking each caller to discover a safe ordering.

This also improves the explanation of errors. An invalid maximum is rejected before it replaces the previous value. Quietly ignoring an invalid assignment would leave callers guessing whether their request succeeded.

The example uses exceptions for invalid arguments. Another API might represent expected rejection with a clearly documented result type. The essential point is that failure should be explicit and the object's state should match the promised failure behavior.

Encapsulation does not require an interface for every type. A public class with meaningful operations already provides a boundary. The broader discussion of OOP and interfaces helps distinguish a consumer-facing contract from a mechanical copy of every member.

Readonly fields do not freeze referenced objects

The word readonly can sound stronger than its field guarantee. Microsoft's readonly reference documentation says that a readonly reference field keeps referring to the same object, but that object's data may still change.

The next example demonstrates this distinction without exposing the field itself. The holder receives a list. A separate variable still refers to the same list and can add an item.

using System;
using System.Collections.Generic;

namespace SharedReferenceExample;

internal static class Program
{
    private static void Main()
    {
        var numbers = new List<int> { 4 };
        var holder = new NumberHolder(numbers);

        numbers.Add(9);
        Console.WriteLine(holder.Count);
    }
}

public sealed class NumberHolder
{
    private readonly List<int> _numbers;

    public NumberHolder(List<int> numbers)
    {
        if (numbers is null)
        {
            throw new ArgumentNullException(nameof(numbers));
        }

        _numbers = numbers;
    }

    public int Count => _numbers.Count;
}

The holder never replaces _numbers, yet the list's membership changes through the other reference. Microsoft's object-reference explanation supports the underlying point: assigning a class reference does not create another object.

That is a shallow boundary. It controls reassignment of the field, not all reachable mutation. A private readonly list may be perfectly reasonable when the containing object controls all references to it. It has a different guarantee when callers retain the supplied list.

Constructor injection does not erase those aliases. Neither does changing the parameter's declared type to a read-only interface. An input contract can restrict the operations visible to the receiver without proving that nobody else can change the underlying instance.

This is why encapsulation reviews should follow references, not only access modifiers. Ask who can reach the object, which operations they can perform, and whether your model depends on those operations not occurring.

Read-only views and snapshots answer different questions

A read-only view says that the consumer does not mutate collection membership through that view. A snapshot says that the returned membership represents a particular point rather than continuing to follow later membership changes.

Microsoft's ReadOnlyCollection example adds an item to the original list and then displays it through the wrapper. The wrapper is not a frozen copy.

The next example offers both choices intentionally. It owns its list rather than accepting an externally retained list. View wraps the current list, while GetSnapshot copies its integer elements into a fresh array before wrapping that array.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace CollectionBoundaryExample;

internal static class Program
{
    private static void Main()
    {
        var readings = new ReadingLog();
        readings.Add(12);

        var view = readings.View;
        var snapshot = readings.GetSnapshot();

        readings.Add(15);

        Console.WriteLine($"View count: {view.Count}");
        Console.WriteLine($"Snapshot count: {snapshot.Count}");
    }
}

public sealed class ReadingLog
{
    private readonly List<int> _readings = new();
    private readonly ReadOnlyCollection<int> _view;

    public ReadingLog()
    {
        _view = _readings.AsReadOnly();
    }

    public IReadOnlyList<int> View => _view;

    public void Add(int reading)
    {
        if (reading < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(reading));
        }

        _readings.Add(reading);
    }

    public IReadOnlyList<int> GetSnapshot()
    {
        return Array.AsReadOnly(_readings.ToArray());
    }
}

List.ToArray copies elements to a new array, and Array.AsReadOnly returns a wrapper. Those two operations serve different purposes: copying separates the membership storage; wrapping restricts modification through the returned collection.

The sample uses integer values so copying elements does not retain references to mutable reading objects. If the list held mutable class instances, an element copy would still refer to those same instances. Freezing collection membership would not freeze their properties.

The snapshot also has a copying cost because it creates new storage. The live view avoids a fresh membership copy but follows the model's later additions. These are educational tradeoffs, not benchmark results or a recommendation that one choice is always faster for an application.

Names help make the difference discoverable. View suggests continuing observation. GetSnapshot suggests a separate representation of current membership. Neither name replaces documentation, but both are clearer than a generic Items property whose temporal behavior is unexplained.

Consider the reader using the returned collection to prepare a report. A continuing view and a captured membership list answer different questions about that report. Explain the temporal promise alongside the mutation promise. That explanation helps callers decide whether they want to observe ongoing additions or work with one captured set, without assuming that the return type alone answers the question.

Collection exposure is part of the invariant

Suppose the log permits only nonnegative readings. Returning its actual mutable list would create another path to add a negative number without calling Add. The validation would still exist, but the public boundary would no longer route every membership change through it.

Returning a wrapper closes that particular path through the returned API. It does not prove every conceivable mutation is impossible, and this article is not treating access control as a security sandbox. It explains the ordinary collection operations callers are offered.

The same reasoning applies to an input collection. If valid state depends on stable membership, storing a caller-owned mutable list directly may let that caller change your assumptions later. Copying membership at construction can separate that boundary, while element references still require their own analysis.

This is closely related to composition in C#: a model can contain collaborators or collections without inheriting them. The responsibility is to explain what it controls and what remains shared.

For larger APIs, the idea of focused interfaces can help describe what a consumer needs to observe. A read-only role can be useful, but should not be mistaken for a deep immutability guarantee.

Immutability requires a precise scope

An object that never changes after construction can simplify some state reasoning. But immutability needs a defined boundary. Are only scalar properties stable? Is collection membership stable? Are the objects inside the collection also stable?

Microsoft's record documentation explicitly includes mutable records and describes init-only properties as shallowly immutable. The record keyword alone is therefore not a substitute for answering those questions.

The range example is mutable but encapsulated. The snapshot separates integer membership at a moment in time. The holder demonstrates a readonly field pointing to mutable shared data. Each has a different guarantee, and none should be described using the same blanket “immutable” label.

Interface design brings similar choices. The discussion of interface drawbacks is relevant because extra abstraction can make boundaries less obvious when its promise is vague. A narrower type is useful only if its meaning remains clear.

Frequently asked questions

What is encapsulation in C#?

It is controlling access to a type's members and representation so callers use its intended boundary. In these examples, constructors and public operations preserve explicitly chosen rules. Private fields support that boundary, but correctness still depends on the code inside the allowed operations.

Is a private field enough to preserve an invariant?

No. A public method can still assign invalid state to a private field. A returned or retained reference can also permit changes elsewhere. Review every permitted transition and every exposed reference rather than counting private members as proof that the model's rule always holds.

Should validation happen in a constructor or method?

For the range, both. Construction establishes a valid starting pair; ChangeBounds validates a proposed replacement pair. The checks concern the same invariant. Other validation may belong at an application boundary, but the range's own meaning should not depend on callers remembering to validate it first.

Does readonly make a list immutable?

Not when it modifies a reference field. It prevents replacing that field after construction under the documented field rules. The list's contents can still change through available operations. The shared-reference example deliberately shows an external alias changing the object without reassigning the holder's field.

Is IReadOnlyList a snapshot?

The interface name does not establish snapshot behavior. The live view and snapshot in this article share that return type but have different membership timing. The implementation and documented contract determine whether later additions remain visible and whether mutable element objects are still shared.

Are records always immutable?

No. Current Microsoft documentation includes records with mutable properties and mutable positional record structs. Init-only reference properties are shallow: the reference is restricted after initialization, while referenced data can still change. Choose and document the guarantee instead of inferring deep immutability from the declaration keyword.

Preserve meaning at the boundary

Encapsulation in C# is about preserving the meaning of an object through its allowed operations. A constructor establishes the initial assumptions. Queries expose relevant information. Transitions validate proposed changes before publishing them through the model.

References deserve the same attention as scalar assignments. Readonly fields, live wrappers, and snapshots solve different problems. When the boundary explains those differences honestly, callers can understand what stays consistent, what may change, and which operations are responsible for the change.

Facade Design Pattern in C#: Complete Guide with Examples

Master the facade design pattern in C# with practical examples showing simplified interfaces, subsystem encapsulation, and clean API design.

What Makes Good Code? - Should Every Class Have An Interface? Pt 2

Continue exploring whether every class needs an interface, focusing on wrapping third party code, encapsulation benefits, and tradeoffs for cleaner code.

Object-Oriented Programming in C#: State, Contracts, and Collaboration

Learn object-oriented programming in C# through state, contracts, and collaboration, with clear examples of encapsulation, inheritance, and composition.

An error has occurred. This application may no longer respond until reloaded. Reload