Encapsulation: Classes That Protect Their Own Rules – C# | Lesson 38
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
publicandprivate. - 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
| Modifier | Who can use the member |
|---|---|
public | Any code that can see the class |
private | Only code inside the same class |
protected | The class and classes that inherit from it (Lesson 39) |
internal | Any 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
| Declaration | Outside code can | Example in the SMS |
|---|---|---|
{ get; } | Read only; set in the constructor | TotalAmount, AdmissionNumber |
{ get; private set; } | Read only; the class changes it | Status |
{ get; init; } | Set once, while creating the object | InvoiceNumber |
| Full property with a setter body | Write, but every value is checked | FirstName |
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:
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:
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
// 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}");
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:
| Code | Error |
|---|---|
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#
| Rule | In SchoolDb | In C# |
|---|---|---|
| Payment within the balance | usp_RecordFeePayment, error 50013 | RecordPayment, error 50013 |
| Status changes with the payment | usp_RecordFeePayment updates both in one transaction | private set, changed only by RecordPayment |
| Names required | NOT NULL columns | Validated 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
- Public setters on everything. Any code can break the rules. Fix: start with
{ get; }orprivate set, and open up only when needed. - Public fields. They cannot validate or calculate. Fix: use properties.
- Storing values that can be calculated. A stored
Balancedrifts out of step with the payments. Fix: compute it, asBalancedoes. - Validation outside the class. Some caller forgets it. Fix: validate inside the setter, constructor, or method.
- Changing two related values separately. Fix: one method changes both together,
like
RecordPayment.
In real 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, andinit; use public setters deliberately. - Put rules in the class that owns the data, in constructors, setters, and methods.
- Compute values like
Balanceinstead 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.
publicis visible everywhere;privateonly inside the class.- Properties can be read-only, privately set,
init-only, or validated. RecordPaymentis 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:
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 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
What does encapsulation mean?
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.
Which declaration lets anyone read Status, but only the class change it?
Show answer
Answer: B. public string Status { get; private set; } get is public; private set limits writing to the class itself.
When can an init property be set?
Show answer
Answer: B. Only while the object is created In the constructor or an object initializer; after that it is fixed.
Why is Balance a computed property instead of a stored value?
Show answer
Answer: B. It can never disagree with the payments It is calculated from the total, discount, and payments every time.
What is an invariant?
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.