There's a latent problem in BiblioTech's model as it stands so far: any part of the program can write book1.Available = true; directly, without going through Lend() or Return(), leaving the catalog in an inconsistent state (for example, a book marked available that's actually still in a member's hands). Encapsulation is the object-oriented programming principle that solves exactly this: hiding an object's internal state and exposing only the controlled operations through which that state can change. In this lesson you'll learn C#'s access modifiers and close that back door on Available, so it can only be modified through the methods designed for that purpose.

Contents

  1. What encapsulation is and why it matters
  2. Access modifiers: public, private, protected, internal
  3. The concrete problem: Available freely modifiable
  4. private set: exposing reading, restricting writing
  5. Properties with validation logic in the set
  6. Read-only properties
  7. Applying encapsulation to the rest of the model: Member and Loan
  8. The principle of minimal exposure

  1. What encapsulation is and why it matters

Encapsulation consists of hiding the internal details of how an object stores and manages its state, exposing to the outside only a controlled interface of valid operations. The core idea is: code outside the class shouldn't be able to leave an object in an inconsistent state. Until now, any code with access to a Book object can write book1.Available = false; without ever having called Lend(), completely bypassing the logic that decides whether the loan is valid (for example, checking that the book was actually available before lending it). Encapsulation closes off that escape route.

  1. Access modifiers: public, private, protected, internal

C# offers several access modifiers that control from where a class member (property, method, field) can be used:

Modifier Accessible from...
public Any part of the program, with no restrictions
private Only from within the class itself
protected From the class itself and from any class that inherits from it
internal From anywhere in the same assembly (compiled project), but not from other external projects

A short, generic example, outside BiblioTech's model, helps pin down the difference between private and protected:

class Base
{
    private int privateValue = 1;
    protected int protectedValue = 2;
    public int publicValue = 3;

    public void ShowFromBase()
    {
        Console.WriteLine(privateValue);   // Ok: same class
        Console.WriteLine(protectedValue); // Ok: same class
    }
}

class Derived : Base
{
    public void ShowFromDerived()
    {
        // Console.WriteLine(privateValue);   // Error: not accessible, it's private in Base
        Console.WriteLine(protectedValue);   // Ok: protected IS accessible from an inheriting class
        Console.WriteLine(publicValue);     // Ok: public, accessible from anywhere
    }
}

Derived obj = new Derived();
Console.WriteLine(obj.publicValue);      // Ok: public
// Console.WriteLine(obj.protectedValue); // Error: protected is not accessible from outside the hierarchy

protected sits at an intermediate position between private (only the class itself) and public (the whole program): it's visible to inheriting classes, but stays hidden from the rest of the code outside that hierarchy. This course will mostly use public and private; protected will appear occasionally when a base class needs to share something with its inheritors without exposing it to the rest of the program, and internal won't be needed in BiblioTech's examples, since the entire project is compiled as a single application.

  1. The concrete problem: Available freely modifiable

Here's how the Available property was defined until now:

class LibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }
    public bool Available { get; set; } = true;

    // ...
}

With a public { get; set; }, nothing prevents this code, anywhere in the program:

Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
book1.Available = true; // Completely bypasses the logic of Lend()/Return()

This direct Available = true doesn't go through any check: it doesn't verify whether there was a loan in progress, it doesn't log any message, it doesn't apply any business rule. The result is an object whose state (Available) can end up out of sync with the rest of the system (for example, with the Loan objects that reference that book).

  1. private set: exposing reading, restricting writing

The most direct solution is to allow Available to be read from anywhere (public get), but only written from within the class itself (private set):

class LibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }
    public bool Available { get; private set; } = true;

    public LibraryItem(string title, string author)
    {
        Title = title;
        Author = author;
    }

    public void Lend()
    {
        if (Available)
        {
            Available = false; // Ok: we're inside the class itself
            Console.WriteLine($"'{Title}' has been lent out.");
        }
        else
        {
            Console.WriteLine($"'{Title}' is not available for loan.");
        }
    }

    public void Return()
    {
        Available = true; // Ok: we're inside the class itself
        Console.WriteLine($"'{Title}' has been returned.");
    }
}
Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");

Console.WriteLine(book1.Available); // Ok: public read, True
book1.Lend();                     // Ok: changes Available internally, through the method
// book1.Available = true;          // Compilation error: the set is private, not accessible from outside

With private set, the last line no longer compiles: the compiler now guarantees (not just by convention or good faith) that code outside the class cannot assign a value to Available without going through Lend() or Return(). This is the simplest and most common way to encapsulate a property in C#: expose reading, lock writing behind operations with meaning (Lend, Return), instead of a generic set.

  1. Properties with validation logic in the set

private set solves the case of Available, but sometimes something more flexible is needed: allowing writing from outside, but validating the value before accepting it. That requires a full property, with an explicit backing field behind it:

class LibraryItem
{
    private string _title;

    public string Title
    {
        get { return _title; }
        set
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                throw new ArgumentException("The title cannot be empty.");
            }

            _title = value;
        }
    }

    // Author, Available, constructor, Lend(), Return() as in the previous section
}

Inside the set block, the value keyword automatically represents the value being assigned. Here, if someone tries to set an empty title or one made up only of whitespace (string.IsNullOrWhiteSpace), the property throws an ArgumentException — recall exception handling from Module 2 — instead of accepting invalid data. _title, with a lowercase leading underscore, is the usual C# convention for naming the private field that backs a property with its own logic.

Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
book1.Title = "";
// Throws ArgumentException: "The title cannot be empty."
Auto-implemented property ({ get; set; }) Full property with validation
Syntax A single line, no visible field Explicit get/set, with its own _title field
Allows validating the value before accepting it No Yes
When to use it The data doesn't need any special restriction There's a business rule that must always be enforced

  1. Read-only properties

A third, stricter level of restriction than private set is to declare no set at all: the property can only be assigned inside the constructor (or via an initializer), and never again afterward:

class Member
{
    public int Id { get; }        // read-only: never changes after the object is created
    public string Name { get; set; }

    public Member(int id, string name)
    {
        Id = id;
        Name = name;
    }
}

public int Id { get; } (with no set of any kind) expresses the intent with total clarity: a member's identifier is set when it's created and must never change during the object's entire lifetime. Trying to write member1.Id = 5; outside the constructor would be a compilation error, just as with private set, but here it's clear that not even the class itself needs to reassign it later.

  1. Applying encapsulation to the rest of the model: Member and Loan

With these three techniques (private set, properties with validation, and read-only properties), the rest of BiblioTech's model can be revised so each piece of data is exposed only with the level of control it deserves:

class Loan
{
    public Book Book { get; }
    public Member Member { get; }
    public DateTime LoanDate { get; }
    public DateTime? ReturnDate { get; private set; }

    public Loan(Book book, Member member)
    {
        Book = book;
        Member = member;
        LoanDate = DateTime.Now;
        ReturnDate = null;
    }

    public void RegisterReturn()
    {
        ReturnDate = DateTime.Now;
    }
}

Now Book, Member and LoanDate on a Loan are read-only (it doesn't make sense for a loan to "change" its book or its member once created), and ReturnDate can only be set from within the Loan class itself, through the RegisterReturn() method — never by assigning the date directly from outside:

Book book1 = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
Member member1 = new Member(1, "Ana Martinez");

book1.Lend();
Loan loan1 = new Loan(book1, member1);

// loan1.ReturnDate = DateTime.Now; // Compilation error: the set is private
loan1.RegisterReturn();              // correct and only way to register the return
book1.Return();

  1. The principle of minimal exposure

Everything covered in this lesson boils down to a single practical principle: expose from your class only the minimum the rest of the program needs to use, and nothing more. Every public property, method, or field is a promise to the rest of the code: the more you expose, the more ways the rest of the program will have of coupling itself to your class's internal details, and the harder it will be to change those details down the road without breaking something. Before marking something as public, ask yourself whether it truly needs to be, or whether private would do (or, in the case of properties, restricting just the set).

Practical rule Applied in BiblioTech
If a piece of data shouldn't change after creation, make it read-only Member.Id, Loan.Book, Loan.Member, Loan.LoanDate
If a piece of data should only change through a specific operation, restrict the set LibraryItem.Available (only via Lend()/Return()), Loan.ReturnDate (only via RegisterReturn())
If a piece of data must satisfy a rule when assigned, use a full property with validation LibraryItem.Title
If a piece of data can change freely with no restriction, a public { get; set; } is enough Member.Name

Common Mistakes and Tips

  • Leaving every property as a public { get; set; } "just in case": it's the most convenient option in the short term, but it gives up all the protection encapsulation offers; review, property by property, whether it genuinely needs to be freely writable from outside.
  • Forgetting to initialize a private field with a full property: if you define _title with no default value or constructor assignment, the Title property would return null until the first valid assignment; make sure the constructor always goes through the set (assigning Title = title;, not _title = title; directly, so validation also applies during construction).
  • Confusing private set with plain private: private set still allows public reading (get remains public); only writing is restricted. A field that's fully private wouldn't be accessible even for reading from outside the class.
  • Using protected as a default solution: protected exposes the member to all current and future inheriting classes, which is also a form of coupling; reserve it for when a base class genuinely needs to share something with its inheritors.
  • Tip: when designing a new class, start by marking everything as private and open up only what the rest of the program genuinely needs, instead of starting with everything public and restricting afterward; it's much easier to open up access later than to close it off without breaking code that already depended on it.

Exercises

  1. Modify the Available property of LibraryItem so it has a public get and a private set (private set). Check that, after creating a Book object, the line book1.Available = true; written outside the class causes a compilation error, while book1.Lend() keeps working normally.

  2. Turn the Title property of LibraryItem into a full property with a private field _title, whose set throws an ArgumentException with the message "The title cannot be empty." if the received value is an empty or whitespace-only string (use string.IsNullOrWhiteSpace). Try assigning an empty title and check that the exception is thrown.

  3. In the Loan class, declare ReturnDate as DateTime? with a public get and a private set, and add a method void RegisterReturn() that assigns it DateTime.Now. Create a Loan, check that ReturnDate starts as null, and after calling RegisterReturn(), check that it now has a value.

Solutions

class LibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }
    public bool Available { get; private set; } = true;

    public LibraryItem(string title, string author)
    {
        Title = title;
        Author = author;
    }

    public void Lend()
    {
        if (Available)
        {
            Available = false;
        }
    }
}

// book1.Available = true; // Compilation error: the set is private
private string _title;

public string Title
{
    get { return _title; }
    set
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new ArgumentException("The title cannot be empty.");
        }

        _title = value;
    }
}

Running book1.Title = ""; throws an ArgumentException with the message "The title cannot be empty.", since the empty string satisfies string.IsNullOrWhiteSpace.

class Loan
{
    public Book Book { get; }
    public Member Member { get; }
    public DateTime LoanDate { get; }
    public DateTime? ReturnDate { get; private set; }

    public Loan(Book book, Member member)
    {
        Book = book;
        Member = member;
        LoanDate = DateTime.Now;
        ReturnDate = null;
    }

    public void RegisterReturn()
    {
        ReturnDate = DateTime.Now;
    }
}

Loan loan1 = new Loan(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"), new Member(1, "Ana Martinez"));
Console.WriteLine(loan1.ReturnDate); // (empty / null)
loan1.RegisterReturn();
Console.WriteLine(loan1.ReturnDate); // current date and time

Conclusion

In this lesson you've protected BiblioTech's model's internal state: access modifiers (public, private, protected, internal), restricting writes with private set, full properties with validation in the set, and read-only properties, applied to LibraryItem.Available, LibraryItem.Title and Loan.ReturnDate. It's no longer possible to leave a book or a loan in an inconsistent state by bypassing business logic: every modification must go through the methods designed for it.

There's still a conceptual loose end: LibraryItem can still be instantiated directly with new LibraryItem("...", "..."), even though in practice it doesn't represent any real BiblioTech material (it only makes sense as a base for Book or Magazine). In the next lesson, Abstraction, you'll learn to prevent that direct instantiation by turning LibraryItem into an abstract class, making it clear in the code itself that its only purpose is to serve as a base for other classes.

© Copyright 2026. All rights reserved