Say you’re coding accounts for a small Columbus, Ohio credit union. Checking pays a $5 fee under $1,500, savings earns 3% a year, and nobody may type a balance in directly.
Those rules map straight onto the OOPs concepts in C#: a class for the account, encapsulation to guard the balance, inheritance and polymorphism for the account types, and abstraction plus interfaces for the shared contract.
I built that one model on .NET 10 and grew it a pillar at a time, with the real output from every run.
What Are the OOPs Concepts in C#?
Object-oriented programming organizes code around objects that hold data and the behavior that works on it. C# gives you a keyword for each idea:
| Concept | C# keywords | What it does for the credit union |
|---|---|---|
| Class and object | class, new | One Account blueprint, one object per member |
| Encapsulation | private, private set | Only Deposit and Withdraw can change a balance |
| Inheritance | :, base, protected | SavingsAccount reuses everything in Account |
| Polymorphism | virtual, override, overloads | One MonthEnd() call, a different rule per type |
| Abstraction | abstract | Callers see CloseMonth(), not the fee math |
| Interfaces | interface | A savings account and a CD share one contract |
Each program below is a .NET 10 file-based app you run with dotnet run File.cs. If you haven’t set that up yet, here’s how to create a console application in Visual Studio Code.
The Sample Credit Union Objects We’ll Use
Every example uses the same two members, so you can follow one balance from section to section:
- Jenna Morales: checking, $1,250.00. She’s under the $1,500 minimum, so she’s the one who pays fees.
- Tyler Brooks: savings, $4,800.00 at 3% a year.
- A $10,000 certificate of deposit (CD) at 4.5% shows up in the interface section.
Jenna’s low balance is the tricky row. It’s what proves each override really runs.
Method 1: Classes and Objects in C# (Start Here)
A class is the blueprint; an object is one account built from it with new. Here’s the code:
using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");
var jenna = new Account("Jenna Morales", 1250m);
var tyler = new Account("Tyler Brooks", 4800m);
var alias = jenna; // same object
Console.WriteLine(jenna.Describe());
Console.WriteLine(tyler.Describe());
Console.WriteLine(ReferenceEquals(alias, jenna));
class Account(string owner, decimal balance)
{
public string Owner { get; } = owner;
public decimal Balance { get; } = balance;
public string Describe() => $"{Owner}: {Balance:C}";
}
Output:
Jenna Morales: $1,250.00
Tyler Brooks: $4,800.00
True

It prints both balances in dollars and True, because alias and jenna are the same object.
How does this code work?
class Account(string owner, decimal balance) uses a primary constructor (C# 12 and later). The parameters flow into the read-only Owner and Balance properties; the later programs use the shorter Owner => owner form.
Each new Account(...) allocates a separate object on the heap. Assigning alias = jenna copies the reference, not the account. The constructor in C# guide covers overloads and chaining.
Note: The first line of every program sets CultureInfo.CurrentCulture to en-US, so :C prints dollars no matter what machine runs it.
Method 2: Encapsulation with private set and Guard Methods
Encapsulation means the object owns its data. Outside code asks the object to change; it never edits the field itself. Same Account class, now with Deposit and Withdraw:
using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");
var acct = new Account("Jenna Morales", 1250m);
acct.Deposit(300m);
Console.WriteLine($"After deposit: {acct.Balance:C}");
try { acct.Withdraw(5000m); }
catch (InvalidOperationException ex) { Console.WriteLine($"Blocked: {ex.Message}"); }
Console.WriteLine($"Balance still: {acct.Balance:C}");
class Account(string owner, decimal opening)
{
public string Owner => owner;
public decimal Balance { get; private set; } = opening;
public void Withdraw(decimal amount)
{
if (amount > Balance)
throw new InvalidOperationException($"{amount:C} is more than {Balance:C}");
Balance -= amount;
}
public void Deposit(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
Balance += amount;
}
}
Output:
After deposit: $1,550.00
Blocked: $5,000.00 is more than $1,550.00
Balance still: $1,550.00

Jenna ends at $1,550.00 after the deposit, and the $5,000 withdrawal is blocked.
How does this code work?
private set lets code inside Account change Balance, but nothing outside it. Every change has to pass the checks in Deposit and Withdraw, and a failed rule becomes an exception the caller handles with try-catch in C#.
Try to set the balance from outside and the compiler stops you:
var acct = new Account();
acct.Balance = 1_000_000m; // try to set it from outside
class Account
{
public decimal Balance { get; private set; } = 1250m;
}
Output:
C:\DotNetDemo\Oops\PrivateSetter.cs(2,1): error CS0272: The property or indexer 'Account.Balance' cannot be used in this context because the set accessor is inaccessible
The build failed. Fix the build errors and run again.
That’s error CS0272, caught before the code ever runs. The full encapsulation in C# tutorial adds validation on property setters and init accessors.
Method 3: Inheritance with a SavingsAccount Derived Class
Inheritance lets a new class reuse an existing one and add to it. Tyler’s savings account is still an account; it just earns interest. Same Account class as the base:
using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");
var savings = new SavingsAccount("Tyler Brooks", 4800m, 0.03m);
savings.Deposit(200m); // inherited from Account
savings.AddMonthlyInterest(); // only SavingsAccount has this
Console.WriteLine(savings.Describe());
Console.WriteLine(savings is Account);
class Account(string owner, decimal opening)
{
public string Owner => owner;
public decimal Balance { get; protected set; } = opening;
public void Deposit(decimal amount) => Balance += amount;
public string Describe() => $"{Owner}: {Balance:C}";
}
class SavingsAccount(string owner, decimal opening, decimal rate)
: Account(owner, opening)
{
public void AddMonthlyInterest() =>
Balance += Math.Round(Balance * rate / 12, 2);
}
Output:
Tyler Brooks: $5,012.50
True
Tyler’s $4,800.00 plus the $200 deposit plus one month at 3% gives $5,012.50, and savings is Account is True.
How does this code work?
: Account(owner, opening) makes SavingsAccount derive from Account and passes the values to the base constructor. Deposit and Describe come along for free.
protected set opens the balance to derived classes only. A class can inherit from just one base class; I explain why in multiple inheritance in C#, and the inheritance in C# guide covers base calls and sealed.
Method 4: Runtime Polymorphism with virtual and override
Polymorphism means one call does the right thing for each type. The bank runs month-end on every account without asking which kind it is. Same two members, one array:
using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");
Account[] accounts = [new CheckingAccount("Jenna Morales", 1250m),
new SavingsAccount("Tyler Brooks", 4800m)];
foreach (Account a in accounts)
{
a.MonthEnd(); // runs the override for the real type
Console.WriteLine($"{a.GetType().Name,-16}{a.Owner,-15}{a.Balance,10:C}");
}
class Account(string owner, decimal opening)
{
public string Owner => owner;
public decimal Balance { get; protected set; } = opening;
public virtual void MonthEnd() { }
}
class CheckingAccount(string o, decimal b) : Account(o, b)
{
public override void MonthEnd() { if (Balance < 1500m) Balance -= 5m; }
}
class SavingsAccount(string o, decimal b) : Account(o, b)
{
public override void MonthEnd() => Balance += Math.Round(Balance * 0.03m / 12, 2);
}
Output:
CheckingAccount Jenna Morales $1,245.00
SavingsAccount Tyler Brooks $4,812.00
![C# runtime polymorphism: Account[] loop calls MonthEnd, CheckingAccount charges $5 and SavingsAccount adds interest](https://aspdotnethelp.com/wp-content/uploads/2026/10/csharp-runtime-polymorphism-virtual-override-accounts.png)
Jenna’s checking drops to $1,245.00 (the $5 fee), and Tyler’s savings grows to $4,812.00.
How does this code work?
MonthEnd is virtual in Account, and each derived class marks its version override. The loop variable is typed Account, but the runtime checks the real object type and calls that type’s method.
Forget override and you get method hiding instead, where the base version runs. The method overriding in C# post shows that bug side by side.
How Does Compile-Time Polymorphism (Overloading) Work in C#?
The other kind of polymorphism happens at compile time: several methods share a name but take different parameters. Same Account class, three Deposit overloads:
using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");
var acct = new Account(1250m);
acct.Deposit(100m);
acct.Deposit(250m, "Mobile check");
acct.Deposit(20m, 35.50m, 12.25m);
class Account(decimal opening)
{
public decimal Balance { get; private set; } = opening;
public void Deposit(decimal amount) => Deposit(amount, "Teller");
public void Deposit(decimal amount, string channel)
{
Balance += amount;
Console.WriteLine($"{channel,-13}+{amount,8:C} = {Balance:C}");
}
public void Deposit(params decimal[] amounts)
{
foreach (decimal amount in amounts) Deposit(amount, "ATM batch");
}
}
Output:
Teller + $100.00 = $1,350.00
Mobile check + $250.00 = $1,600.00
ATM batch + $20.00 = $1,620.00
ATM batch + $35.50 = $1,655.50
ATM batch + $12.25 = $1,667.75
Jenna’s $1,250.00 grows through five deposits to $1,667.75.
How does this code work?
The compiler picks the overload from the arguments. One decimal matches Deposit(decimal), a decimal plus a string matches the two-parameter version, and three decimals fall to params decimal[].
The simpler overloads call the full one, so the balance rule lives in one place. More patterns are in method overloading in C#.
Method 5: Abstraction with an Abstract Class
Abstraction hides the how and shows the what. The teller app should call one method to close the month and never see the fee math. Same Account class, now abstract, with Tyler’s business account (Brooks Lawn Care) added:
using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");
Account[] accounts = [new CheckingAccount("Jenna Morales", 1250m),
new BusinessAccount("Brooks Lawn Care", 4800m)];
foreach (var a in accounts) Console.WriteLine(a.CloseMonth());
abstract class Account(string owner, decimal opening)
{
public string Owner => owner;
public decimal Balance { get; private set; } = opening;
protected abstract decimal MonthlyFee(); // each type decides
public string CloseMonth() // callers only see this
{
decimal fee = MonthlyFee();
Balance -= fee;
return $"{Owner,-17} fee {fee,6:C} balance {Balance,10:C}";
}
}
class CheckingAccount(string o, decimal b) : Account(o, b)
{
protected override decimal MonthlyFee() => Balance < 1500m ? 5m : 0m;
}
class BusinessAccount(string o, decimal b) : Account(o, b)
{
protected override decimal MonthlyFee() => 15m;
}
Output:
Jenna Morales fee $5.00 balance $1,245.00
Brooks Lawn Care fee $15.00 balance $4,785.00

Jenna pays $5.00 and lands at $1,245.00; Brooks Lawn Care pays the flat $15.00 and lands at $4,785.00.
How does this code work?
abstract class Account can’t be created with new, and MonthlyFee() has no body, so every derived class must supply one. CloseMonth() is the public face; the fee rule stays protected.
This is the template-method pattern. The abstraction in C# tutorial goes wider, and abstract class in C# covers constructors and abstract properties.
Method 6: Interfaces for Shared Contracts
An interface is a contract with no instance state. Use it when unrelated types need the same ability. Same savings account, plus a CD that isn’t an Account at all:
using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");
List<IInterestBearing> earners =
[new SavingsAccount("Tyler Brooks", 4800m), new CertificateOfDeposit(10_000m)];
foreach (var e in earners)
{
Console.WriteLine($"{e.GetType().Name,-21}{e.Rate,6:P1} 1 yr: {e.InterestFor(12),8:C}");
if (e is IStatement s) Console.WriteLine($" statement: {s.Summary()}");
}
interface IInterestBearing
{
decimal Rate { get; }
decimal InterestFor(int months);
}
interface IStatement { string Summary(); }
class SavingsAccount(string owner, decimal balance) : IInterestBearing, IStatement
{
public decimal Rate => 0.03m;
public decimal InterestFor(int months) => Math.Round(balance * Rate * months / 12, 2);
public string Summary() => $"{owner}: {balance:C}";
}
class CertificateOfDeposit(decimal principal) : IInterestBearing
{
public decimal Rate => 0.045m;
public decimal InterestFor(int months) => Math.Round(principal * Rate * months / 12, 2);
}
Output:
SavingsAccount 3.0% 1 yr: $144.00
statement: Tyler Brooks: $4,800.00
CertificateOfDeposit 4.5% 1 yr: $450.00

Tyler’s $4,800.00 earns $144.00 in a year at 3.0%, and the $10,000 CD earns $450.00 at 4.5%.
How does this code work?
SavingsAccount implements two interfaces at once, which a class can’t do with base classes. The list only knows IInterestBearing, so e is IStatement s checks for the second contract before calling Summary().
That pattern match is a safe cast; see type casting in C#. The interface in C# guide covers default interface methods too.
Where Do Structs, Records and Partial Classes Fit?
A struct supports encapsulation and interfaces but not inheritance, so it suits small values like a money amount. Here’s structure in C# in depth.
A record adds value equality: two records with the same data are equal, while two Account classes are only equal when they’re the same object.
A partial class in C# is just one class split across files, common with generated code. It doesn’t change any of the OOP rules above.
Which OOP Concept Should You Reach For?
| Situation | Use | Why |
|---|---|---|
| Data plus the rules that change it | Class + private set | Only the class’s methods touch the data |
| A type that’s a special version of another | Inheritance | Reuse the base, add what’s new |
| Same call, different behavior per type | virtual / override | The runtime picks the right method |
| Same action, different inputs | Overloads | The compiler picks by arguments |
| Shared steps, one step varies | Abstract class | Base runs the flow, subclasses fill the gap |
| Unrelated types, same ability | Interface | A class can implement many |
I start with a plain class and encapsulation, then add an interface before I reach for inheritance.
Things to Keep in Mind
- Don’t use public setters on rule-bound data. A public
Balancesetter skips every check; useprivate setand methods. - Mark overrides with
override. Without it you hide the base method, and a base-typed variable still runs the old code. - Use
decimalfor money.doublecan’t store $0.10 exactly, so cents drift. - Set the culture for currency output.
:Cfollows the machine’s culture unless you set en-US.
These posts are worth reading once the four pillars click:
- Delegates in C# with real examples
- Events in C# and the publisher-subscriber pattern
- Dispose vs Finalize in C#
- C# interview questions for freshers
Frequently Asked Questions
What are the 4 pillars of OOP in C#?
Encapsulation, inheritance, polymorphism and abstraction. Classes and objects are the base they all sit on, and interfaces are how C# expresses many contracts.
Does C# support multiple inheritance?
Not for classes. A class inherits from one base class but can implement any number of interfaces.
What’s the difference between an abstract class and an interface in C#?
An abstract class can hold fields, constructors and shared code, and a class can inherit only one. An interface holds no instance state, and a class can implement many.
What’s the difference between overloading and overriding?
Overloading is several methods with the same name and different parameters, picked at compile time. Overriding replaces a virtual method in a derived class, picked at runtime.
Is encapsulation the same as abstraction?
No. Encapsulation hides data behind methods; abstraction hides how a task is done behind a simple interface. They usually work together.
Where can I read Microsoft’s official OOP docs for C#?
Start with Object-oriented programming in C# on Microsoft Learn, which builds a bank account model too.
Bijay Kumar is a renowned software engineer, accomplished author, and distinguished Microsoft Most Valuable Professional (MVP) specializing in SharePoint. With a rich professional background spanning over 15 years, Bijay has established himself as an authority in the field of information technology. He possesses unparalleled expertise in multiple programming languages and technologies such as ASP.NET, ASP.NET MVC, C#.NET, and SharePoint, which has enabled him to develop innovative and cutting-edge solutions for clients across the globe. Read more…