OOP Concepts · Practice

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.

Advanced 6 min Lesson 35 of 38Examples use modern JavaScript (ES2022 private fields) and C# 13.
0/38 in OOP Concepts
On this page · 6 min · Advanced

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#

C#Program.cs Illustrative
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);
Expected output
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#

  1. Both accounts are stored as the base type Account — polymorphism.
  2. Deposit and TryWithdraw are shared code in the abstract base; CanWithdraw is the abstract blank.
  3. Savings declines because 7500 is more than its 7000 balance; Current allows it thanks to the 2000 overdraft.
  4. Balance has a protected setter: Program cannot write account.Balance = 1000000.
  5. Only SavingsAccount implements IInterestBearing, so interest is a capability, not part of every account.
  6. 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 setters on entity state

public decimal Balance { get; set; } lets any controller change balances directly, bypassing the rules.

Casting without checking

(IInterestBearing)accounts[1] would throw InvalidCastException. Use if (account is IInterestBearing ib) when unsure.

Leaving classes unsealed by default

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?
CanWithdraw always returns false; YearlyInterest returns Balance * 0.07m.
Show solution
C# Illustrative
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#

OOP in C# quizMultiple choice Question 1 of 3

In the example, which feature stops Program from setting Balance directly?

Score so far: 0/0

Want more? Practise all OOP Concepts MCQs · OOP Concepts interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.