Skip to main content

Encapsulation: Classes That Protect Their Own Rules – C# | Lesson 38

Published / updated

Introduction​

In Lesson 37, every property of Student had a public set, so any code anywhere could change anything.

For a name, that is mostly harmless. For a fee invoice, it is dangerous: one careless line could mark an invoice "Paid" without a single payment.

Encapsulation means a class keeps its data private and offers only safe ways to change it. The invoice decides its own status, based on the payments it receives. This is the first of the four OOP pillars.

By the end of this lesson, you will be able to:

  • Use the access modifiers public and private.
  • Explain the difference between a field and a property.
  • Choose between get-only, private set, init, and validated setters.
  • Keep an object always valid, with methods as the only way to change it.
  • Read the compiler errors that protect encapsulated data.

Why this matters​

In Lesson 22, usp_RecordFeePayment made sure a payment and the invoice status always change together. The C# side needs the same guarantee. If any code can set Status, then one day some code will, and a family's fees will show "Paid" with nothing received.

Encapsulation turns "please remember the rules" into "the rules cannot be broken". The compiler itself stops the wrong code, before it ever runs.

The concept​

Example: what encapsulation means​

Think of the school's fee counter. Parents hand money through the window and get a receipt. Nobody reaches into the cash box or writes in the ledger directly.

The counter is the public part: the few safe actions anyone can use. The cash box and ledger are private: only the clerk touches them, and only by following the rules. In code, RecordPayment is the window, and the payment total is the cash box.

Example: access modifiers​

ModifierWho can use the member
publicAny code that can see the class
privateOnly code inside the same class
protectedThe class and classes that inherit from it (Lesson 39)
internalAny code in the same project

When you write nothing, class members are private. Start with private, and make something public only when other code truly needs it.

Example: fields and properties​

A field is a variable inside a class, such as private decimal _amountPaid;. By convention, private fields start with an underscore.

A property looks like a field from outside, but runs code when read or written. That lets the class check values, calculate results, or refuse changes. Public data should almost always be a property, never a public field.

Example: four kinds of property​

DeclarationOutside code canExample in the SMS
{ get; }Read only; set in the constructorTotalAmount, AdmissionNumber
{ get; private set; }Read only; the class changes itStatus
{ get; init; }Set once, while creating the objectInvoiceNumber
Full property with a setter bodyWrite, but every value is checkedFirstName

A computed property, such as Balance, is a fifth kind: it has no stored value at all.

Apply it to the SMS​

FeeInvoice is new in SchoolApp.Core. It follows the same rules, and uses the same error numbers, as the SQL procedure from Lesson 22:

src/SchoolApp.Core/Entities/FeeInvoice.cs
using SchoolApp.Core.Exceptions;

namespace SchoolApp.Core.Entities;

// Lesson 38: an invoice that protects its own rules (encapsulation).
public class FeeInvoice
{
private decimal _amountPaid; // a private field: only code inside this class can change it

public int Id { get; set; }
public string InvoiceNumber { get; init; } // init: can be set only while the object is created
public decimal TotalAmount { get; } // no setter: fixed by the constructor
public decimal DiscountAmount { get; }
public string Status { get; private set; } = "Unpaid"; // anyone can read; only this class can write

public FeeInvoice(string invoiceNumber, decimal totalAmount, decimal discountAmount = 0m)
{
if (totalAmount < 0)
{
throw new ArgumentOutOfRangeException(nameof(totalAmount), "The total cannot be negative.");
}
if (discountAmount < 0 || discountAmount > totalAmount)
{
throw new ArgumentOutOfRangeException(nameof(discountAmount), "The discount must be between 0 and the total.");
}

InvoiceNumber = invoiceNumber;
TotalAmount = totalAmount;
DiscountAmount = discountAmount;
}

public decimal AmountPaid => _amountPaid;
public decimal Balance => TotalAmount - DiscountAmount - _amountPaid;

// The only way to pay. The same rules and error numbers as usp_RecordFeePayment (Lesson 22).
public void RecordPayment(decimal amount)
{
if (Status == "Cancelled")
{
throw new SchoolRuleException(50011, "Invoice is cancelled.");
}
if (amount <= 0)
{
throw new SchoolRuleException(50012, "Amount must be more than zero.");
}
if (amount > Balance)
{
throw new SchoolRuleException(50013, "Amount is more than the balance due.");
}

_amountPaid += amount;
Status = Balance == 0 ? "Paid" : "PartiallyPaid";
}

// An invoice with payments on it cannot be cancelled; the payments must be refunded first.
public void Cancel()
{
if (_amountPaid > 0)
{
throw new InvalidOperationException("An invoice with payments cannot be cancelled.");
}
Status = "Cancelled";
}
}

Student now checks and trims names, and its admission number can never change:

src/SchoolApp.Core/Entities/Student.cs
namespace SchoolApp.Core.Entities;

// Lesson 37: a class describes what every student has (properties) and can do (methods).
// Lesson 38: names are checked and trimmed; the admission number can never change.
public class Student
{
private string _firstName = "";
private string _lastName = "";

public int Id { get; set; }
public string AdmissionNumber { get; } // set once, in the constructor

public string FirstName
{
get => _firstName;
set => _firstName = RequireName(value, nameof(FirstName));
}

public string LastName
{
get => _lastName;
set => _lastName = RequireName(value, nameof(LastName));
}

public DateOnly DateOfBirth { get; set; }

// The constructor runs when an object is created with new.
public Student(string admissionNumber, string firstName, string lastName, DateOnly dateOfBirth)
{
AdmissionNumber = admissionNumber;
FirstName = firstName;
LastName = lastName;
DateOfBirth = dateOfBirth;
}

// A computed property: worked out from other properties each time it is read.
public string FullName => $"{FirstName} {LastName}";

// A method: the age on a given date, the same rule as Lesson 29's SQL task.
public int GetAgeOn(DateOnly date)
{
int age = date.Year - DateOfBirth.Year;
if (DateOfBirth > date.AddYears(-age))
{
age--; // the birthday has not happened yet in that year
}
return age;
}

// A private helper: only this class can call it.
private static string RequireName(string value, string propertyName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{propertyName} is required.", propertyName);
}
return value.Trim();
}
}
Show src/SchoolApp.Console/Program.cs
src/SchoolApp.Console/Program.cs
// Lesson 38: encapsulation. FeeInvoice and Student protect their own data and rules.
using SchoolApp.Core.Entities;
using SchoolApp.Core.Exceptions;

// 1. Diya's invoice: the only way to change the status is to record a payment.
FeeInvoice invoice = new FeeInvoice("INV-2026-000002", 45500m) { Id = 2 };
Console.WriteLine($"{invoice.InvoiceNumber}: {invoice.Status}, balance {invoice.Balance}");

invoice.RecordPayment(20000m);
Console.WriteLine($"After 20000: {invoice.Status}, paid {invoice.AmountPaid}, balance {invoice.Balance}");

// 2. Broken rules are refused with the same error numbers as the SQL procedure.
decimal[] attempts = { 30000m, 0m };
foreach (decimal amount in attempts)
{
try
{
invoice.RecordPayment(amount);
}
catch (SchoolRuleException ex)
{
Console.WriteLine($"Paying {amount}: error {ex.ErrorNumber}, {ex.Message}");
}
}

invoice.RecordPayment(25500m);
Console.WriteLine($"After 25500: {invoice.Status}, balance {invoice.Balance}");

// 3. A paid invoice cannot be cancelled.
try
{
invoice.Cancel();
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Cancel refused: {ex.Message}");
}

// 4. Student checks and cleans names in the property setter.
Student aarav = new Student("ADM-2017-0001", " Aarav ", "Sharma", new DateOnly(2011, 4, 12));
Console.WriteLine($"Stored first name: [{aarav.FirstName}]");

try
{
aarav.LastName = " ";
}
catch (ArgumentException ex)
{
Console.WriteLine($"Rename refused: {ex.Message}");
}
Console.WriteLine($"Name is still: {aarav.FullName}");
Output
INV-2026-000002: Unpaid, balance 45500
After 20000: PartiallyPaid, paid 20000, balance 25500
Paying 30000: error 50013, Amount is more than the balance due.
Paying 0: error 50012, Amount must be more than zero.
After 25500: Paid, balance 0
Cancel refused: An invoice with payments cannot be cancelled.
Stored first name: [Aarav]
Rename refused: LastName is required. (Parameter 'LastName')
Name is still: Aarav Sharma

Example: the only way to pay​

RecordPayment checks the rules first, then changes two things together: the private total _amountPaid and the Status. Nothing else in the program can change either one.

The numbers follow Diya's real invoice: 45,500 in total, 20,000 paid makes it PartiallyPaid with 25,500 due. A payment of 30,000 fails with 50013, the same error the SQL procedure raises.

Notice the order inside the method: check every rule first, change state last. If a check fails, nothing has changed yet, so the invoice is never left half-updated. It is the same idea as a transaction in Lesson 24, applied to one object.

Example: an object that is always valid​

A rule that must always be true for an object is called an invariant. For FeeInvoice, the invariant is: the status matches the payments.

Because the constructor checks the starting values, and RecordPayment and Cancel are the only ways to change the state, no sequence of calls can break it. You never need to "double-check" an invoice's status elsewhere in the program.

Example: validated setters​

FirstName has a full property: a get that returns the private field, and a set that calls RequireName. " Aarav " is stored as Aarav, and a blank last name is refused.

The setter runs every time, also when the constructor assigns the name. So a Student with a blank name cannot be created, and cannot be produced later by renaming.

Example: init-only properties​

InvoiceNumber uses init. It can be set in the constructor or in an object initializer, the { Id = 2 } part after new FeeInvoice(...). After that, it is fixed.

Use init for values that are chosen once and never change, such as numbers on printed receipts. Lesson 43 shows records, which use init everywhere.

Example: the compiler protects the data​

Try to break the rules from Program.cs, and the build fails:

CodeError
invoice.Status = "Paid";error CS0200: Property or indexer 'FeeInvoice.Status' cannot be assigned to -- it is read only
invoice.TotalAmount = 1m;error CS0200: ... 'FeeInvoice.TotalAmount' cannot be assigned to -- it is read only
invoice.InvoiceNumber = "INV-X";error CS8852: Init-only property or indexer 'FeeInvoice.InvoiceNumber' can only be assigned in an object initializer ...
invoice._amountPaid = 45500m;error CS1061: 'FeeInvoice' does not contain a definition for '_amountPaid' ...

From another project, a private set looks completely read-only, and a private field looks as if it does not exist. That is exactly the point.

Example: tell, don't ask​

Without encapsulation, every screen that takes a payment would have to ask the invoice for its balance, check the rules itself, and then update two values. Ten screens means ten copies of the rules, and one of them will be wrong.

With encapsulation, the screen simply tells the invoice: RecordPayment(20000m). The invoice applies its own rules. This style is called "tell, don't ask", and it keeps rules in one place.

Example: readonly fields​

A field marked readonly, such as private readonly decimal _limit;, can be set only in its declaration or in the constructor. After that, not even the class's own methods can change it.

It is the field version of a { get; } property. Use it for values that an object needs internally and that must never change after creation.

Example: why Id still has a public setter​

Id is { get; set; } in both classes. That is deliberate: the database creates the Id with IDENTITY, and in Module 9 the data access code sets it after reading or inserting the row.

Encapsulation is not "make everything private". It is a decision per property: who legitimately needs to change this value, and through which rules?

Example: rules in the database and in C#​

RuleIn SchoolDbIn C#
Payment within the balanceusp_RecordFeePayment, error 50013RecordPayment, error 50013
Status changes with the paymentusp_RecordFeePayment updates both in one transactionprivate set, changed only by RecordPayment
Names requiredNOT NULL columnsValidated setters

Both layers check the same rules. C# gives fast, friendly messages to the user; the database is the last guard if any code path skips C#. This is called defense in depth.

There is still one gap. In Lesson 28, SchoolAppRole received UPDATE on the whole schema, so a careless SQL statement could still set an invoice's status directly.

Closing it is a design decision: grant the app only EXECUTE on the payment procedure for that table. Strict systems do this for money tables; the course keeps the simpler grant so the later modules can show several data access styles.

Common mistakes​

  1. Public setters on everything. Any code can break the rules. Fix: start with { get; } or private set, and open up only when needed.
  2. Public fields. They cannot validate or calculate. Fix: use properties.
  3. Storing values that can be calculated. A stored Balance drifts out of step with the payments. Fix: compute it, as Balance does.
  4. Validation outside the class. Some caller forgets it. Fix: validate inside the setter, constructor, or method.
  5. Changing two related values separately. Fix: one method changes both together, like RecordPayment.

In real projects​

From 16 years of .NET projects

A school once reported invoices marked "Paid" with no payment. A support screen had a dropdown that wrote straight to the status column, added "just for corrections".

Over a year, staff used it as a shortcut, and the accounts never balanced. We removed the dropdown and made status a private result of payments, as in this lesson.

Corrections then went through refunds and adjustments, which leave a trail. The accounts balanced again within a month.

Best practices​

  • Make fields private, and expose data through properties.
  • Prefer { get; }, private set, and init; use public setters deliberately.
  • Put rules in the class that owns the data, in constructors, setters, and methods.
  • Compute values like Balance instead of storing them.
  • Keep every object valid from creation to the end of its life.

Recap​

  • Encapsulation: a class keeps its data private and offers safe ways to change it.
  • public is visible everywhere; private only inside the class.
  • Properties can be read-only, privately set, init-only, or validated.
  • RecordPayment is the only way to change an invoice's payments and status.
  • The compiler refuses code that tries to bypass the rules (CS0200, CS8852, CS1061).

Homework​

Library module task: Write a BookCopy class whose Status can be read by anyone but changed only by its methods. Issue() works only on an Available copy, Return() only on an Issued copy, and MarkLost() marks it Lost.

A correct result looks like:

Output
The Jungle Book copy 1: Available
After Issue: Issued
Second Issue refused: Copy 1 is Issued.
After Return: Available
Issue refused: Copy 1 is Lost.
Show the solution
library-assignment/038-library-encapsulation.cs
// Library homework, Lesson 38: a book copy whose status changes only through its methods.
// Run with: dotnet run library-assignment/038-library-encapsulation.cs
BookCopy copy = new BookCopy("The Jungle Book", 1);
Console.WriteLine($"{copy.Title} copy {copy.CopyNumber}: {copy.Status}");

copy.Issue();
Console.WriteLine($"After Issue: {copy.Status}");

try
{
copy.Issue();
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Second Issue refused: {ex.Message}");
}

copy.Return();
Console.WriteLine($"After Return: {copy.Status}");

copy.MarkLost();
try
{
copy.Issue();
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Issue refused: {ex.Message}");
}

class BookCopy
{
public string Title { get; }
public int CopyNumber { get; }
public string Status { get; private set; } = "Available";

public BookCopy(string title, int copyNumber)
{
Title = title;
CopyNumber = copyNumber;
}

public void Issue()
{
if (Status != "Available")
{
throw new InvalidOperationException($"Copy {CopyNumber} is {Status}.");
}
Status = "Issued";
}

public void Return()
{
if (Status != "Issued")
{
throw new InvalidOperationException($"Copy {CopyNumber} is not issued.");
}
Status = "Available";
}

public void MarkLost() => Status = "Lost";
}

In SQL, the Lesson 23 trigger keeps BookCopies.Status in step with BookIssues. Now C# enforces the same rule before the database is even asked.

Quiz​

  1. What does encapsulation mean?

    1. Putting all classes in one file
    2. A class keeps its data private and offers safe ways to change it
    3. Copying objects
    4. Using inheritance
    Show answer

    Answer: B. A class keeps its data private and offers safe ways to change it The class controls how its own data changes.

  2. Which declaration lets anyone read Status, but only the class change it?

    1. public string Status { get; set; }
    2. public string Status { get; private set; }
    3. private string Status { get; set; }
    4. public string Status;
    Show answer

    Answer: B. public string Status { get; private set; } get is public; private set limits writing to the class itself.

  3. When can an init property be set?

    1. Any time
    2. Only while the object is created
    3. Never
    4. Only inside methods
    Show answer

    Answer: B. Only while the object is created In the constructor or an object initializer; after that it is fixed.

  4. Why is Balance a computed property instead of a stored value?

    1. It is faster
    2. It can never disagree with the payments
    3. Properties cannot store decimals
    4. C# requires it
    Show answer

    Answer: B. It can never disagree with the payments It is calculated from the total, discount, and payments every time.

  5. What is an invariant?

    1. A variable that never changes
    2. A rule that must always be true for an object
    3. A private field
    4. A kind of exception
    Show answer

    Answer: B. A rule that must always be true for an object For FeeInvoice: the status always matches the payments.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: OOP interview questions.

Fresher (0-2 yrs)​

F What is encapsulation? concept

Keeping an object's data private and offering only safe, public ways to change it, so the object enforces its own rules. A fee invoice's status can change only through RecordPayment.

See the example · Permalink

F What do the access modifiers public, private, protected, and internal mean? concept

public: any code. private: only the same class (the default for members). protected: the class and classes that inherit from it. internal: any code in the same project (assembly).

See the example · Permalink

F What is the difference between a field and a property? concept

A field is a variable inside a class. A property looks like a field from outside but runs code when read or written, so it can validate, calculate, or restrict access. Public data should be exposed through properties, not public fields.

See the example · Permalink

Mid (3-5 yrs)​

M What is the difference between { get; }, { get; private set; }, and { get; init; }? concept

{ get; } can be set only in the constructor. { get; private set; } can be changed by the class's own methods at any time. { get; init; } can be set in the constructor or an object initializer, and is fixed afterwards.

See the example · Permalink

M What is a class invariant? Give an example. concept

A rule that must always be true for an object. For a fee invoice: the status always matches the payments. The constructor checks the starting state and every method keeps the rule, so no sequence of calls can break it.

See the example · Permalink

M What does 'tell, don't ask' mean? concept

Instead of asking an object for its data and applying rules outside it, tell the object what to do and let it apply its own rules, such as invoice.RecordPayment(amount). This keeps each rule in one place.

See the example · Permalink

Senior (6-9 yrs)​

S Invoices show 'Paid' without any payments. How would you find the cause and stop it happening again? scenario

Find every code path that writes the status, such as an admin screen or a direct SQL update. Make the status private set, changed only by the payment method, and restrict direct updates in the database. Correct existing invoices through proper adjustments that leave an audit trail.

How to answer: Cover investigation (who writes the field), the design fix (encapsulation), the database side (permissions or procedures), and data repair. Mention audit logs to find when it happened.

See the example · Permalink

S Should every property be private or read-only? When is a public setter fine? design

No. Decide per property who legitimately changes it. Ids are set by data access code, simple descriptive fields may be freely editable, but values tied to rules, such as status or balance, should change only through methods.

How to answer: Show judgment rather than a blanket rule. Give examples of each case and mention that DTOs, used only to carry data, often have public setters by design.

See the example · Permalink

Architect / Lead (10+ yrs)​

A Business rules exist both in SQL constraints and procedures and in C# classes. Is that duplication a problem? design

It is deliberate defense in depth: C# gives fast, clear feedback, and the database protects the data if any path skips the application. Keep the rules consistent with shared error numbers and tests, and decide which layer is authoritative for each rule.

How to answer: Weigh duplication against safety. Explain which rules belong in the database (integrity) and which in code (workflow), and how to keep both in step.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-38-encapsulation (code repository coming soon)
  • Files: src/SchoolApp.Core/Entities/FeeInvoice.cs, src/SchoolApp.Core/Entities/Student.cs, src/SchoolApp.Console/Program.cs, library-assignment/038-library-encapsulation.cs
  • Video: coming soon

What's next​

Students, teachers, and guardians all have a first name, a last name, and a full name. Next: inheritance, which lets them share that code instead of copying it. Lesson 39: Inheritance

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.