OOP in C#
C# was designed as an object-oriented language from day one. This lesson puts every concept from the track into one small, real program so you can see how they fit together.
On this page · 6 min · Advanced
On this page
- In simple words
- Learning objectives
- Explanation
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
In simple words#
C# was built for OOP from day one — classes, interfaces and access modifiers are like built-in furniture in a well-designed house.
- Strong typing
- Types checked at compile time.
- Properties
- Built-in getters/setters.
- Interfaces + DI
- The backbone of ASP.NET Core.
Learning objectives#
- Recognise each OOP concept in idiomatic C#
- Combine an abstract class with an interface
- Use sealed, protected and records appropriately
- Read and write a complete small C# domain model
Explanation#
Encapsulation: Balance has a protected setter, so only account classes change it, and only through Deposit and TryWithdraw.
Abstraction and the template method: Account.TryWithdraw is written once and calls the abstract CanWithdraw, which each account type decides.
Inheritance and polymorphism: SavingsAccount and CurrentAccount inherit from Account; the loop treats them all as Account and each applies its own rule.
Interfaces: only savings accounts implement IInterestBearing — a capability, not a parent.
sealed stops further inheritance where it is not designed for; a record models an immutable transaction with value equality and free ToString.
Primary constructors (class SavingsAccount(string owner, decimal opening)) keep the code short; they are C# 12 syntax.
Code example: Bank accounts using every OOP concept#
var accounts = new List<Account>
{
new SavingsAccount("Asha", 6000m),
new CurrentAccount("Ravi", 5000m, overdraftLimit: 2000m),
};
foreach (var account in accounts)
{
account.Deposit(1000m);
bool ok = account.TryWithdraw(7500m);
Console.WriteLine(quot;{account.Owner,-5} {account.GetType().Name,-15} withdraw 7500: {(ok ? "OK" : "declined")}, balance {account.Balance}");
}
var savings = (IInterestBearing)accounts[0];
Console.WriteLine(quot;Interest this year: {savings.YearlyInterest()}");
Console.WriteLine(new Transaction("Ravi", -7500m));
public interface IInterestBearing
{
decimal YearlyInterest();
}
public abstract class Account(string owner, decimal opening)
{
public string Owner { get; } = owner;
public decimal Balance { get; protected set; } = opening;
public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentException("Amount must be positive");
Balance += amount;
}
public bool TryWithdraw(decimal amount)
{
if (!CanWithdraw(amount)) return false;
Balance -= amount;
return true;
}
protected abstract bool CanWithdraw(decimal amount);
}
public sealed class SavingsAccount(string owner, decimal opening)
: Account(owner, opening), IInterestBearing
{
protected override bool CanWithdraw(decimal amount) => amount <= Balance;
public decimal YearlyInterest() => Math.Round(Balance * 0.04m, 2);
}
public sealed class CurrentAccount(string owner, decimal opening, decimal overdraftLimit)
: Account(owner, opening)
{
protected override bool CanWithdraw(decimal amount) => amount <= Balance + overdraftLimit;
}
public record Transaction(string Owner, decimal Amount);
Asha SavingsAccount withdraw 7500: declined, balance 7000
Ravi CurrentAccount withdraw 7500: OK, balance -1500
Interest this year: 280.00
Transaction { Owner = Ravi, Amount = -7500 }This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- Both accounts are stored as the base type Account — polymorphism.
- Deposit and TryWithdraw are shared code in the abstract base; CanWithdraw is the abstract blank.
- Savings declines because 7500 is more than its 7000 balance; Current allows it thanks to the 2000 overdraft.
- Balance has a protected setter: Program cannot write account.Balance = 1000000.
- Only SavingsAccount implements IInterestBearing, so interest is a capability, not part of every account.
- Transaction is a record: immutable, with a readable ToString for logs.
Real-world use case#
This is exactly the shape of a domain layer in an ASP.NET Core app: entities with protected state and behaviour methods, abstract bases for shared rules, interfaces for capabilities, and records for DTOs and events.
Common mistakes#
public decimal Balance { get; set; } lets any controller change balances directly, bypassing the rules.
(IInterestBearing)accounts[1] would throw InvalidCastException. Use if (account is IInterestBearing ib) when unsure.
Classes not designed for inheritance can be sealed. It documents intent and lets the runtime optimise calls.
Best practices#
- Protect state with private or protected setters
- Put shared rules in an abstract base and varying rules in overrides
- Use interfaces for optional capabilities
- Use records for immutable data and DTOs
Remember this#
Encapsulation: protected set. Abstraction: abstract CanWithdraw. Inheritance: : Account. Polymorphism: List<Account>.
Interface = optional capability (IInterestBearing).
sealed = “not designed for inheritance”.
Practice exercise#
Your task: Add a FixedDepositAccount (sealed) that never allows withdrawals and pays 7% interest via IInterestBearing. Create one with 10000, try to withdraw 100 and print the interest.
Need a hint?
Show solution
var fd = new FixedDepositAccount("Meera", 10000m);
Console.WriteLine(fd.TryWithdraw(100m)); // False
Console.WriteLine(fd.YearlyInterest()); // 700.00
public interface IInterestBearing { decimal YearlyInterest(); }
public abstract class Account(string owner, decimal opening)
{
public string Owner { get; } = owner;
public decimal Balance { get; protected set; } = opening;
public bool TryWithdraw(decimal amount)
{
if (!CanWithdraw(amount)) return false;
Balance -= amount;
return true;
}
protected abstract bool CanWithdraw(decimal amount);
}
public sealed class FixedDepositAccount(string owner, decimal opening)
: Account(owner, opening), IInterestBearing
{
protected override bool CanWithdraw(decimal amount) => false;
public decimal YearlyInterest() => Math.Round(Balance * 0.07m, 2);
}
Mini quiz#
In the example, which feature stops Program from setting Balance directly?
Want more? Practise all OOP Concepts MCQs · OOP Concepts interview questions
My notes#
Saved in this browser.