C# Encapsulation
Encapsulation means keeping an object’s internal data protected and exposing only the operations other code is allowed to use. In C#, this is usually done with access modifiers, fields, properties, methods, and constructors. Good encapsulation keeps objects valid, makes code easier to change, and prevents the rest of the program from depending on fragile implementation details.
Overview: How Encapsulation Works
In object-oriented programming, a class should not simply be a bag of public variables. A class represents a concept, and it should control how that concept can be used. For example, a bank account should not allow outside code to set a negative balance directly. Instead, outside code should call methods such as Deposit and Withdraw, and those methods can enforce the rules.
C# supports encapsulation mainly through access modifiers. A private member can be used only inside the containing type. A public member can be used from any code that can access the type. protected exposes members to derived classes, internal exposes members inside the same assembly, and private protected or protected internal combine inheritance and assembly rules. For beginner OOP design, the most important habit is simple: keep fields private, then expose behavior through carefully chosen public properties and methods.
Encapsulation is not only about hiding data. It is about preserving invariants. An invariant is a rule that should always be true for an object, such as Age being non-negative or an order total never including a negative quantity. If every state change goes through code owned by the class, the class gets one central place to validate and normalize input.
Under the hood, the CLR stores instance fields as part of each object. Public and private fields are both real memory in the object; the difference is compile-time accessibility. The C# compiler checks whether the calling code is allowed to access a member. If you try to read a private field from another class, compilation fails before the program runs. Properties often look like fields from the outside, but the compiler turns property access into method calls: a getter method and, if allowed, a setter method. That means a property can validate, calculate, log, or restrict access while still offering clean syntax.
Syntax
public class BankAccount
{
private decimal balance;
public decimal Balance
{
get { return balance; }
private set { balance = value; }
}
public void Deposit(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
Balance += amount;
}
}
| Part | Purpose |
|---|---|
private decimal balance; |
Stores object state that outside code cannot touch directly. |
public decimal Balance |
Exposes a controlled view of the balance. |
get |
Allows callers to read the value. |
private set |
Allows only the class itself to assign the property. |
Deposit |
Provides a public operation that validates before changing state. |
Examples
Example 1: A Property That Validates Input
using System;
class Person
{
private string name = string.Empty;
public Person(string name)
{
Name = name;
}
public string Name
{
get { return name; }
set
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Name cannot be blank.");
}
name = value.Trim();
}
}
}
class Program
{
static void Main()
{
Person person = new Person(" Ada Lovelace ");
Console.WriteLine(person.Name);
try
{
person.Name = " ";
}
catch (ArgumentException ex)
{
Console.WriteLine("Validation blocked: " + ex.Message);
}
}
}
Output:
Ada Lovelace
Validation blocked: Name cannot be blank.
The private field name stores the value, but all assignments go through the Name property. The constructor also uses the property, so construction and later updates follow the same validation rule. The object trims valid names and rejects blank ones, keeping its state consistent.
Example 2: A Bank Account With Controlled Changes
using System;
using System.Globalization;
class BankAccount
{
public string Owner { get; }
public decimal Balance { get; private set; }
public BankAccount(string owner, decimal openingBalance)
{
if (string.IsNullOrWhiteSpace(owner))
{
throw new ArgumentException("Owner is required.");
}
if (openingBalance < 0)
{
throw new ArgumentOutOfRangeException(nameof(openingBalance));
}
Owner = owner.Trim();
Balance = openingBalance;
}
public void Deposit(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
Balance += amount;
}
public bool TryWithdraw(decimal amount)
{
if (amount <= 0 || amount > Balance)
{
return false;
}
Balance -= amount;
return true;
}
}
class Program
{
static void Main()
{
BankAccount account = new BankAccount("Mina", 100m);
account.Deposit(40m);
bool paid = account.TryWithdraw(75m);
Console.WriteLine(account.Owner);
Console.WriteLine(account.Balance.ToString("0.00", CultureInfo.InvariantCulture));
Console.WriteLine(paid);
}
}
Output:
Mina
65.00
True
Balance has a public getter and a private setter. Other code can read the balance, but it cannot assign account.Balance = -500m. The only way to change the balance is through methods that understand the account rules. TryWithdraw returns false instead of throwing for a normal business failure, which is often a cleaner API for expected outcomes.
Example 3: Encapsulating a Collection
using System;
using System.Collections.Generic;
using System.Linq;
class Order
{
private readonly List<string> items = new List<string>();
public IReadOnlyList<string> Items
{
get { return items; }
}
public void AddItem(string itemName)
{
if (string.IsNullOrWhiteSpace(itemName))
{
throw new ArgumentException("Item name is required.");
}
items.Add(itemName.Trim());
}
public int ItemCount
{
get { return items.Count; }
}
}
class Program
{
static void Main()
{
Order order = new Order();
order.AddItem("Notebook");
order.AddItem("Pen");
Console.WriteLine(order.ItemCount);
Console.WriteLine(string.Join(", ", order.Items));
Console.WriteLine(order.Items.GetType().Name);
}
}
Output:
2
Notebook, Pen
List`1
The private List<string> gives the class efficient internal storage, while the public property exposes it as IReadOnlyList<string>. Callers can read and enumerate items, but they do not receive an API that advertises Add or Remove. For stricter protection against casting, a class can return a copy or a read-only wrapper; the key idea is that collection mutation should still go through AddItem.
How It Works Step by Step
- The compiler reads access modifiers and records which code is allowed to use each member.
- When outside code tries to access
person.name, the compiler sees thatnameisprivateand reports an error. - When outside code accesses
person.Name, the compiler emits a call to the property’s getter or setter method. - The setter runs normal C# code, so it can inspect
value, throw exceptions, trim text, or update a backing field. - The CLR executes the resulting methods at runtime. Access checks are mostly enforced by compilation, while metadata still records visibility for runtime and reflection scenarios.
Auto-properties such as public decimal Balance { get; private set; } are shorthand. The compiler creates a hidden backing field for you. Use an auto-property when no custom logic is needed in the getter or setter. Use an explicit private field when you need validation, normalization, lazy calculation, or a defensive copy.
Common Mistakes
Mistake 1: Public Fields
public class Product
{
public decimal Price;
}
This compiles as a class definition, but it is poor encapsulation because any caller can assign -10m. Once other code depends on a public field, changing it to a property can also become a breaking change for some compiled consumers.
using System;
class Product
{
public decimal Price { get; private set; }
public Product(decimal price)
{
SetPrice(price);
}
public void SetPrice(decimal price)
{
if (price < 0)
{
throw new ArgumentOutOfRangeException(nameof(price));
}
Price = price;
}
}
class Program
{
static void Main()
{
Product product = new Product(19.99m);
product.SetPrice(24.50m);
Console.WriteLine(product.Price);
}
}
Output:
24.50
Mistake 2: A Setter That Bypasses Validation
public int Age { get; set; }
This property is convenient, but it allows -1, 900, or any other integer. Auto-properties are fine when every value of the type is valid. When only some values are valid, write the rule into the class.
using System;
class Profile
{
private int age;
public int Age
{
get { return age; }
set
{
if (value < 0 || value > 130)
{
throw new ArgumentOutOfRangeException(nameof(value), "Age must be between 0 and 130.");
}
age = value;
}
}
}
class Program
{
static void Main()
{
Profile profile = new Profile();
profile.Age = 42;
Console.WriteLine(profile.Age);
}
}
Output:
42
Best Practices
- Keep instance fields
privateunless there is a strong reason not to. - Expose behavior with methods when an operation changes state or has business meaning.
- Expose simple facts with read-only properties or properties with private setters.
- Validate in constructors as well as in methods, so objects cannot start invalid.
- Prefer meaningful method names such as
Deposit,Cancel, orAddItemover generic setters when rules are involved. - Be careful with mutable collections. Exposing a
List<T>directly gives callers mutation power. - Do not make everything public for testing. Test through the public API, or use internal APIs deliberately when needed.
- Keep encapsulation practical. A property with no rules and no expected future rules can be an auto-property.
Practice Exercises
- Create a
Temperatureclass with a Celsius value that cannot go below absolute zero. Add a read-only Fahrenheit property. - Create a
ShoppingCartclass that stores private line items and exposes an item count plus methods to add and remove items. - Create a
Studentclass whereGrademust be between 0 and 100. Try setting invalid grades and handle the exception.
Summary
- Encapsulation protects object state by controlling access to fields and operations.
privatefields plus public properties and methods are the core C# pattern.- Properties are method calls in clean syntax, so they can validate and normalize data.
- Constructors should create valid objects from the beginning.
- Well-encapsulated classes are easier to change because outside code depends on behavior, not internal storage.
