Overview
A bank account is the textbook example of why encapsulation exists in the first place, and C#'s exception handling is what turns "reject an invalid withdrawal" from a silent bug into an explicit, unmissable event. The `Balance` property in `Account` can only be assigned from inside the class, so the only way to change it from outside is by calling `Deposit()` or `Withdraw()` — and `Withdraw()` does not simply return `false` on failure, it throws a custom `InsufficientFundsException`.
One important difference from Java matters here: C# has no concept of checked exceptions. Every exception in C# — including custom ones you write yourself — is what Java would call unchecked: the compiler never forces a method signature to declare `throws SomeException`, and it never forces a caller to wrap a call in `try`/`catch`. That makes exception handling in C# a matter of team convention and documentation (usually an XML doc comment noting which exceptions a method can throw) rather than something the compiler enforces — so `try`/`catch` around `Withdraw()` in this project is something you choose to write because it is the right thing to do, not because the compiler leaves you no choice.
By the end of this tutorial you will have a console banking application built around an `Account` class with a per-account transaction history, a `Bank` class managing many accounts at once, and a menu loop that wraps every withdrawal in a `try`/`catch` block around your own exception type.
- A custom exception class, `InsufficientFundsException`, thrown by an overdrawn withdrawal.
- An `Account` class with `AccountNumber`, `HolderName`, and `Balance` properties that can only change through validated methods.
- `Deposit()` and `Withdraw()` methods that validate amounts before ever changing the balance.
- A per-account transaction history stored in a `List<string>`.
- A `Bank` class that owns multiple accounts and assigns unique account numbers automatically.
- A menu loop with a `try`/`catch` around every withdrawal to handle `InsufficientFundsException` gracefully.
Prerequisites
- Classes — properties, constructors, and methods.
- Encapsulation — why state stays behind properties with restricted setters, exposed only through controlled `public` methods.
- Exception handling — `try`/`catch`, `throw`, and writing a class that derives from `Exception`.
- Collections basics — `List<string>` and iterating it with `foreach`.
- Method basics — parameters, return types, and calling one method from another.
Project Structure
The program lives in a single file, `Program.cs`, containing four types: `InsufficientFundsException`, `Account`, `Bank`, and the top-level statements that drive the menu. Real-world C# projects conventionally split each of these into its own file — `InsufficientFundsException.cs`, `Account.cs`, `Bank.cs` — since, unlike Java, C# places no restriction at all on how many classes a file may contain or what they must be named; this tutorial keeps everything in one file purely so the whole program can be read top to bottom without switching between files.
`Account` owns its own `Balance` and transaction history, and is solely responsible for keeping them consistent. `Bank` owns a collection of `Account` objects and layers account-number lookup and creation on top, but it never reaches into an `Account`'s private state directly — it only ever calls `Deposit()`, `Withdraw()`, and the read-only properties, the same boundary any other caller would have to respect.
Step 1: Define a Custom Exception
A custom exception in C# is a class that derives from `System.Exception` (or a more specific built-in exception type), typically adding nothing more than constructors that forward a message to the base class. Microsoft's own guidance is to always derive from `Exception` (never throw a bare `Exception` instance) and to give the type a name ending in `Exception`, both of which `InsufficientFundsException` follows here.
// A custom exception type. Unlike Java, C# does not distinguish "checked" from// "unchecked" exceptions — this class behaves like any other exception in the// language, with no compiler-enforced "throws" declaration or catch requirement.class InsufficientFundsException : Exception{ public InsufficientFundsException(string message) : base(message) { // ": base(message)" forwards the message to Exception's own constructor, // which is what makes it show up later when something reads ex.Message. }}Step 2: Define the Account Class
Every mutable property uses a restricted setter, and `Balance` in particular is never exposed as anything a caller could mutate directly — `{ get; private set; }` means code outside `Account` can read the current balance but can only change it by going through `Deposit()`/`Withdraw()`. `History` starts with one entry recorded by the constructor, so even an account that has never had a transaction shows when and with what balance it was opened.
using System.Collections.Generic;
class Account{ public int AccountNumber { get; private set; } // Unique id assigned by the Bank when the account is created public string HolderName { get; private set; } // Account holder's name; not something Deposit/Withdraw ever touch public double Balance { get; private set; } // Current balance; private set means only this class can change it private List<string> _history = new List<string>(); // Log of every transaction made on this account
public Account(int accountNumber, string holderName, double startingBalance) { AccountNumber = accountNumber; HolderName = holderName; Balance = startingBalance; _history.Add($"Opened with balance {startingBalance:F2}"); // First history entry }}Step 3: Deposit and Withdraw With Exception Handling
`Deposit()` validates its input and simply returns if the amount is invalid, since a bad deposit amount is a routine, expected situation that does not need an exception. `Withdraw()` is different: an overdraft is the one failure mode this whole project is built around highlighting, so it is signaled with `throw new InsufficientFundsException(...)` instead of a silent return. Because C# has no `throws` clause, nothing in the method signature announces that this can happen — which is exactly why the XML-style `///` summary comment above it exists, documenting the behavior for any caller reading the method's signature in their editor.
public void Deposit(double amount){ if (amount <= 0) // Reject zero or negative deposits up front { Console.WriteLine("Deposit amount must be positive."); return; } Balance += amount; // Encapsulation: only this method may change Balance _history.Add($"Deposited {amount:F2}, new balance {Balance:F2}"); Console.WriteLine($"Deposited {amount:F2}. New balance: {Balance:F2}");}
/// <summary>Withdraws <paramref name="amount"/> from the account.</summary>/// <exception cref="InsufficientFundsException">Thrown when the balance cannot cover the withdrawal.</exception>public void Withdraw(double amount) // No "throws" clause exists in C# — the doc comment above is the only warning a caller gets{ if (amount <= 0) // Reject zero or negative withdrawal amounts { Console.WriteLine("Withdrawal amount must be positive."); return; } if (amount > Balance) // Overdraft: this is the scenario the exception exists for { throw new InsufficientFundsException($"Insufficient funds. Current balance: {Balance:F2}"); } Balance -= amount; // Both checks passed; safe to subtract _history.Add($"Withdrew {amount:F2}, new balance {Balance:F2}"); Console.WriteLine($"Withdrew {amount:F2}. New balance: {Balance:F2}");}Click Run to see what this code prints.
Step 4: Track Transaction History
Both `Deposit()` and `Withdraw()` already append to `_history` as part of their normal operation in Step 3; this step just adds the method that plays that history back. Because `Withdraw()` only reaches the `_history.Add(...)` line after every validation check and the exception-throwing branch have already passed, `PrintHistory()` can trust that every entry in the list represents a transaction that genuinely happened.
public void PrintHistory(){ Console.WriteLine($"Transaction history for account #{AccountNumber}:"); foreach (var entry in _history) // foreach over every recorded entry, in chronological order { Console.WriteLine($" - {entry}"); }}Step 5: Manage Multiple Accounts With a Bank Class
A single `Account` is not a bank — `Bank` is what turns it into one, owning a `List<Account>` and a counter that hands out the next unused account number, mirroring the id-assignment pattern from the Student Management Console App project. `FindAccount()` returns the actual `Account` object (or `null`), which the caller can then call `Deposit()`/`Withdraw()` on directly.
class Bank{ private List<Account> _accounts = new List<Account>(); // Every account currently open at this bank private int _nextAccountNumber = 1001; // First account created this run gets number 1001
public Account CreateAccount(string holderName, double startingBalance) { var account = new Account(_nextAccountNumber, holderName, startingBalance); _accounts.Add(account); Console.WriteLine($"Account created! Account number: {_nextAccountNumber}"); _nextAccountNumber++; // Guarantees every future account gets a unique number return account; }
public Account? FindAccount(int accountNumber) { foreach (var acc in _accounts) { if (acc.AccountNumber == accountNumber) { return acc; // Found it: return the real object so callers can operate on it directly } } return null; // No match; callers must check for null before using the result }
public void ListAccounts() { Console.WriteLine("\nAccount#\tHolder\t\tBalance"); foreach (var acc in _accounts) { Console.WriteLine($"{acc.AccountNumber}\t\t{acc.HolderName}\t\t{acc.Balance:F2}"); } }}Step 6: Build the Menu Loop
The withdrawal branch of the menu is the one place `InsufficientFundsException` actually gets caught: the `acc.Withdraw(amount)` call sits inside a `try` block, and if it throws, the `catch (InsufficientFundsException e)` block prints `e.Message` and the loop simply continues to the next menu prompt. Because C# does not force this `try`/`catch` the way Java would for a checked exception, it is worth noting explicitly: nothing in the compiler stops you from calling `Withdraw()` without a `try`/`catch` at all — if you did, an unhandled `InsufficientFundsException` would propagate all the way up and crash the program, exactly like any other uncaught .NET exception.
var bank = new Bank();int choice;
do{ Console.WriteLine("\n===== BANK ACCOUNT SIMULATOR ====="); Console.WriteLine("1. Create Account"); Console.WriteLine("2. Deposit"); Console.WriteLine("3. Withdraw"); Console.WriteLine("4. Check Balance"); Console.WriteLine("5. View Transaction History"); Console.WriteLine("6. List All Accounts"); Console.WriteLine("7. Exit"); Console.Write("Enter your choice: "); choice = int.Parse(Console.ReadLine()!);
if (choice == 1) { Console.Write("Enter account holder name: "); string name = Console.ReadLine()!; Console.Write("Enter starting balance: "); double startingBalance = double.Parse(Console.ReadLine()!); if (startingBalance < 0) // Defend against a negative opening balance { Console.WriteLine("Starting balance cannot be negative. Setting balance to 0."); startingBalance = 0; } bank.CreateAccount(name, startingBalance); } else if (choice == 2) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); if (acc is null) { Console.WriteLine("Account not found."); } else { Console.Write("Enter deposit amount: "); double amount = double.Parse(Console.ReadLine()!); acc.Deposit(amount); } } else if (choice == 3) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); if (acc is null) { Console.WriteLine("Account not found."); } else { Console.Write("Enter withdrawal amount: "); double amount = double.Parse(Console.ReadLine()!); try { acc.Withdraw(amount); // May throw InsufficientFundsException } catch (InsufficientFundsException e) { Console.WriteLine($"Withdrawal failed: {e.Message}"); // Handled here; program keeps running } } } else if (choice == 4) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); Console.WriteLine(acc is null ? "Account not found." : $"Account #{acc.AccountNumber} ({acc.HolderName}) - Balance: {acc.Balance:F2}"); } else if (choice == 5) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); if (acc is null) { Console.WriteLine("Account not found."); } else { acc.PrintHistory(); } } else if (choice == 6) { bank.ListAccounts(); } else if (choice == 7) { Console.WriteLine("Goodbye!"); } else { Console.WriteLine("Invalid choice, try again."); // Catches anything outside 1-7 }} while (choice != 7); // Keep looping until the user explicitly picks ExitComplete Code
Here is the full program with the menu logic and every type assembled in the correct order, ready to save as `Program.cs` inside a project created with `dotnet new console` and run with `dotnet run`.
using System.Collections.Generic;
var bank = new Bank();int choice;
do{ Console.WriteLine("\n===== BANK ACCOUNT SIMULATOR ====="); Console.WriteLine("1. Create Account"); Console.WriteLine("2. Deposit"); Console.WriteLine("3. Withdraw"); Console.WriteLine("4. Check Balance"); Console.WriteLine("5. View Transaction History"); Console.WriteLine("6. List All Accounts"); Console.WriteLine("7. Exit"); Console.Write("Enter your choice: "); choice = int.Parse(Console.ReadLine()!);
if (choice == 1) { Console.Write("Enter account holder name: "); string name = Console.ReadLine()!; Console.Write("Enter starting balance: "); double startingBalance = double.Parse(Console.ReadLine()!); if (startingBalance < 0) { Console.WriteLine("Starting balance cannot be negative. Setting balance to 0."); startingBalance = 0; } bank.CreateAccount(name, startingBalance); } else if (choice == 2) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); if (acc is null) { Console.WriteLine("Account not found."); } else { Console.Write("Enter deposit amount: "); double amount = double.Parse(Console.ReadLine()!); acc.Deposit(amount); } } else if (choice == 3) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); if (acc is null) { Console.WriteLine("Account not found."); } else { Console.Write("Enter withdrawal amount: "); double amount = double.Parse(Console.ReadLine()!); try { acc.Withdraw(amount); } catch (InsufficientFundsException e) { Console.WriteLine($"Withdrawal failed: {e.Message}"); } } } else if (choice == 4) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); Console.WriteLine(acc is null ? "Account not found." : $"Account #{acc.AccountNumber} ({acc.HolderName}) - Balance: {acc.Balance:F2}"); } else if (choice == 5) { Console.Write("Enter account number: "); int accNum = int.Parse(Console.ReadLine()!); var acc = bank.FindAccount(accNum); if (acc is null) { Console.WriteLine("Account not found."); } else { acc.PrintHistory(); } } else if (choice == 6) { bank.ListAccounts(); } else if (choice == 7) { Console.WriteLine("Goodbye!"); } else { Console.WriteLine("Invalid choice, try again."); }} while (choice != 7);
class InsufficientFundsException : Exception{ public InsufficientFundsException(string message) : base(message) { }}
class Account{ public int AccountNumber { get; private set; } public string HolderName { get; private set; } public double Balance { get; private set; } private List<string> _history = new List<string>();
public Account(int accountNumber, string holderName, double startingBalance) { AccountNumber = accountNumber; HolderName = holderName; Balance = startingBalance; _history.Add($"Opened with balance {startingBalance:F2}"); }
public void Deposit(double amount) { if (amount <= 0) { Console.WriteLine("Deposit amount must be positive."); return; } Balance += amount; _history.Add($"Deposited {amount:F2}, new balance {Balance:F2}"); Console.WriteLine($"Deposited {amount:F2}. New balance: {Balance:F2}"); }
public void Withdraw(double amount) { if (amount <= 0) { Console.WriteLine("Withdrawal amount must be positive."); return; } if (amount > Balance) { throw new InsufficientFundsException($"Insufficient funds. Current balance: {Balance:F2}"); } Balance -= amount; _history.Add($"Withdrew {amount:F2}, new balance {Balance:F2}"); Console.WriteLine($"Withdrew {amount:F2}. New balance: {Balance:F2}"); }
public void PrintHistory() { Console.WriteLine($"Transaction history for account #{AccountNumber}:"); foreach (var entry in _history) { Console.WriteLine($" - {entry}"); } }}
class Bank{ private List<Account> _accounts = new List<Account>(); private int _nextAccountNumber = 1001;
public Account CreateAccount(string holderName, double startingBalance) { var account = new Account(_nextAccountNumber, holderName, startingBalance); _accounts.Add(account); Console.WriteLine($"Account created! Account number: {_nextAccountNumber}"); _nextAccountNumber++; return account; }
public Account? FindAccount(int accountNumber) { foreach (var acc in _accounts) { if (acc.AccountNumber == accountNumber) { return acc; } } return null; }
public void ListAccounts() { Console.WriteLine("\nAccount#\tHolder\t\tBalance"); foreach (var acc in _accounts) { Console.WriteLine($"{acc.AccountNumber}\t\t{acc.HolderName}\t\t{acc.Balance:F2}"); } }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a PIN property to `Account` and throw a new `InvalidPinException` when a deposit, withdrawal, or history lookup is attempted with the wrong PIN.
- Write a `TransferFunds(Account from, Account to, double amount)` method on `Bank` that withdraws from one account and deposits into another as one operation, propagating `InsufficientFundsException` if the source account cannot cover it.
- Switch `Bank`'s storage from `List<Account>` to `Dictionary<int, Account>` keyed by account number for O(1) lookups.
- Persist accounts and their transaction history to a JSON file using `System.Text.Json` so data survives between runs.
- Add an interest calculation feature that applies a fixed annual rate to every account's balance when the user selects a "Run Interest Cycle" menu option.
Summary
You built a working bank account simulator that uses encapsulation the way real C# programs do: `Account` protects its own balance behind restricted-setter properties and validated public methods, and a custom `InsufficientFundsException` turns an overdraft into a clearly named, purposeful signal rather than a silently ignored edge case — even without Java-style checked exceptions forcing the issue. The `try`/`catch` pattern around `Withdraw()` you wrote in the menu loop is the same pattern you will use anywhere a C# method needs to signal "this specific, expected failure just happened" without crashing the whole program.