OOP in C#: The Four Pillars, Honestly
Encapsulation, inheritance, polymorphism and abstraction, and which ones you should really use
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
Why does BankAccount use a private setter for Balance?
Real-world analogy
What is it?
Object-oriented programming organises code as objects that own their data and expose behaviour. The four pillars are encapsulation (protect the rules) and inheritance (reuse by is-a). The others are polymorphism (one call, different results) and abstraction (hide details behind a contract). In practice, modern C# leans on encapsulation, interfaces and composition, and uses inheritance sparingly.
Real-world relevance
A checkout service takes an IPaymentMethod. It does not care if it is a card, a wallet or a bank transfer. Adding a new method means adding one class. In the same system, the account has a private balance. A bug in the UI can never write a negative number straight into it.
Key points
- Encapsulation guards the rules — An object keeps its data private and changes it only through methods that check the rules. public decimal Balance { get; private set; } lets anyone read it but nobody write it.
- Private setters stop bypassing — If a setter is public, a caller can skip your validation and break the object. A private setter forces every change through one checked door.
- Inheritance means is-a — A Dog is an Animal, so it can inherit from it. The child is tied to the parent: change the parent and every child changes too.
- Composition is the default — Composition means has-a: an Order holds a PaymentMethod instead of inheriting from one. It is easier to swap and does not lock you into a tree.
- Polymorphism: one call, many behaviours — You call Pay() on a base type or interface, and the real object decides what happens. A base method marked virtual is replaced with override.
- Abstract class or interface — An abstract class can hold state, constructors and shared code, but a class has only one parent. An interface is a contract with no instance fields, and a class can implement many.
- Abstraction hides the how — Callers see IPaymentMethod.Pay() and never the card network behind it. You can change the inside without touching them.
- sealed and record — sealed stops anyone from inheriting your class, which is a safe default for most classes. A record is a type compared by its values, great for small data like Money(10, "USD").
Quick check
Answer from memory — scrolling up first makes it stick less.
You want a class that has shared state and a constructor, and subclasses must implement one method. What fits?
Which is the better default when you want to reuse behaviour?
Code example
public class BankAccount
{
public decimal Balance { get; private set; }
public BankAccount(decimal opening) => Balance = opening;
public void Withdraw(decimal amount)
{
if (amount <= 0) throw new ArgumentException("Amount must be positive.");
if (amount > Balance) throw new InvalidOperationException("Not enough money.");
Balance -= amount;
}
}
public interface IPaymentMethod { string Pay(decimal amount); }
public sealed class CardPayment : IPaymentMethod
{
public string Pay(decimal amount) => $"Charged {amount} to card";
}
public sealed class WalletPayment : IPaymentMethod
{
public string Pay(decimal amount) => $"Took {amount} from wallet";
}
public static class Checkout
{
public static void Run(IEnumerable<IPaymentMethod> methods)
{
foreach (var method in methods) Console.WriteLine(method.Pay(10m));
}
}Line-by-line walkthrough
- 1. Balance can be read by anyone but set only inside the class, because the setter is private.
- 2. Withdraw is the only door. It checks the amount is positive and that there is enough money before it changes the balance.
- 3. IPaymentMethod is a contract with one method. It says what a payment can do, not how.
- 4. CardPayment and WalletPayment are sealed classes that each implement the contract their own way.
- 5. Checkout.Run takes a list of IPaymentMethod. It calls Pay on each, and the real object decides what runs.
- 6. Adding a bank transfer means writing one new class. Checkout does not change at all.
Spot the bug
public class BankAccount
{
public decimal Balance { get; set; }
public void Withdraw(decimal amount)
{
if (amount > Balance) throw new InvalidOperationException("Not enough money.");
Balance -= amount;
}
}
// somewhere in the UI layer
account.Balance -= 5000;Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Object-oriented programming in C# (Microsoft Learn)
- Records in C# (Microsoft Learn)
Lock it in