LearnAI ToolsCareerPractice BuildsPlayContact
C++ ProgrammingIntermediate~2 hours

Bank Management System

Build an account system with deposits, withdrawals, and transaction history using classes and file handling.

ClassesFile I/OEncapsulation

Overview

A bank management system is one of the clearest ways to see why C++ classes exist in the first place. An account is not just a bundle of data — it is data that must never be changed except through a small set of controlled operations, because a balance that anyone could edit directly would make overdraft checks and transaction history meaningless. That is exactly what encapsulation gives you: the `balance` field lives inside `Account`, marked `private`, and the only way to change it from outside the class is by calling `deposit()` or `withdraw()`, both of which validate the amount before touching anything.

By the end of this tutorial you will have a console banking application built around an `Account` class and a `Bank` class that manages many accounts at once, complete with per-account transaction history and file persistence using `<fstream>` so account data survives between runs. Along the way you will see the C++ idiom of declaring methods inside a class and defining their bodies afterward with the `ClassName::methodName` scope resolution syntax, which is exactly how larger real-world C++ programs are organized.

What You'll Build
  • An `Account` class with private `accountNumber`, `holderName`, and `balance` fields.
  • A `deposit()` and `withdraw()` method pair that validate amounts before changing the balance.
  • A per-account transaction history stored in a `std::vector<std::string>` and printable on demand.
  • A `Bank` class that owns a `std::vector<Account>` and assigns unique account numbers automatically.
  • File persistence using `std::ofstream`/`std::ifstream` so accounts survive between program runs.
  • A menu loop tying account creation, deposits, withdrawals, and balance/history lookups together.

Prerequisites

  • Classes — defining a `class` with `private`/`public` sections, member functions, and constructors.
  • Encapsulation — why data is kept `private` and only exposed through controlled `public` methods.
  • The Standard Library basics — `std::string`, `std::vector`, and range-based `for` loops.
  • File handling in C++ — opening files with `std::ofstream`/`std::ifstream` and reading lines with `std::getline`.
  • Functions — declaring a method inside a class and defining it afterward with `ClassName::methodName(...)`.

Project Structure

The whole program lives in a single file, `bank.cpp`. Two classes do all the work: `Account` models one bank account and enforces its own rules (no negative deposits, no overdrafts), and `Bank` owns a `std::vector<Account>` representing every account currently open, plus the logic for creating new accounts and finding an existing one by number. Rather than repeating class bodies at every step, each class is declared once with its methods listed but not yet defined, and later steps fill in the method bodies one at a time using the `Account::methodName` and `Bank::methodName` syntax — the same declare-then-define split real C++ header/source files use, just kept in one file for simplicity.

Persistence uses plain comma-separated text, not raw binary structs like a C program might use. That is a deliberate choice: `Account` contains a `std::string` and a `std::vector<std::string>`, and neither type can be safely written to disk with a raw byte copy, since both manage memory behind the scenes. Writing "accountNumber,holderName,balance" as one text line per account, and parsing it back with `std::stringstream`, is the idiomatic C++ way to persist objects that contain strings or containers.

Step 1: Define the Account Class

We start with the includes, a small `formatMoney` helper that turns a `double` into a fixed two-decimal string (so `1500` prints as `1500.00` instead of a long floating-point tail), and the `Account` class itself. The private section holds the data that must stay protected; the public section lists every operation callers are allowed to perform, with only the trivial one-line accessors defined inline and the rest left as declarations to be defined in the next two steps.

#include <iostream> // std::cin/std::cout for all console interaction
#include <fstream> // std::ifstream/std::ofstream for reading and writing account data to disk
#include <sstream> // std::stringstream/std::ostringstream for parsing and formatting text
#include <iomanip> // std::fixed/std::setprecision for two-decimal money formatting
#include <string> // std::string and std::to_string/std::stoi/std::stod
#include <vector> // std::vector to hold each account's transaction history and the bank's accounts
// Formats a monetary amount as a fixed two-decimal string, e.g. 1500 -> "1500.00"
std::string formatMoney(double amount) {
std::ostringstream oss; // Builds the formatted text in memory before returning it
oss << std::fixed << std::setprecision(2) << amount; // Force exactly two digits after the decimal point
return oss.str(); // Extract the finished string from the stream
}
class Account {
private:
int accountNumber; // Unique ID assigned by the Bank when the account is created
std::string holderName; // Account holder's name
double balance; // Current balance; private so it can only change via deposit()/withdraw()
std::vector<std::string> history; // Log of every transaction made on this account
public:
Account(int accNum, const std::string &name, double startingBalance); // Declared here, defined in Step 2
int getAccountNumber() const { return accountNumber; } // Trivial accessors are fine to define inline
std::string getHolderName() const { return holderName; } // Read-only view of the holder's name
double getBalance() const { return balance; } // The only way outside code can read the balance
void deposit(double amount); // Declared now, defined in Step 3
void withdraw(double amount); // Declared now, defined in Step 3
void printHistory() const; // Declared now, defined in Step 3
};

Notice that `deposit`, `withdraw`, and `printHistory` appear only as declarations here — a return type, name, and parameter list ending in a semicolon, with no `{ }` body. The compiler now knows these methods exist and what they look like, which is enough to compile code that calls them; the actual bodies are supplied later using `Account::deposit(...)`, and the linker matches them up by name.

Step 2: Build the Account Constructor

The constructor uses a member initializer list — the `: accountNumber(accNum), holderName(name), balance(startingBalance)` part — which runs before the constructor's body and is the preferred C++ way to set a member's initial value, rather than assigning to it inside `{ }`. The body then records the account's opening balance as the first entry in its transaction history.

Account::Account(int accNum, const std::string &name, double startingBalance)
: accountNumber(accNum), holderName(name), balance(startingBalance) { // Member initializer list, runs before the body
history.push_back("Opened with balance " + formatMoney(startingBalance)); // First entry in this account's history
}

The `Account::` prefix is the scope resolution syntax that tells the compiler "this is the definition of the constructor I declared inside `class Account`," rather than a brand-new, unrelated function that happens to share a name.

Step 3: Deposit, Withdraw, and Transaction History

Both `deposit` and `withdraw` validate their input before ever touching `balance`, which is exactly the protection encapsulation is meant to provide — no code outside this class can create an invalid balance, because no code outside this class can touch `balance` at all. Every successful operation appends a human-readable line to `history`, which `printHistory` can replay later.

void Account::deposit(double amount) {
if (amount <= 0) { // Reject zero or negative deposits up front
std::cout << "Deposit amount must be positive.\n";
return;
}
balance += amount; // Encapsulation: only this method may change balance
history.push_back("Deposited " + formatMoney(amount) + ", new balance " + formatMoney(balance));
std::cout << "Deposited " << formatMoney(amount) << ". New balance: " << formatMoney(balance) << "\n";
}
void Account::withdraw(double amount) {
if (amount <= 0) { // Reject zero or negative withdrawal amounts
std::cout << "Withdrawal amount must be positive.\n";
return;
}
if (amount > balance) { // Reject overdrafts before balance is ever touched
std::cout << "Insufficient funds. Current balance: " << formatMoney(balance) << "\n";
return;
}
balance -= amount; // Both checks passed, safe to subtract
history.push_back("Withdrew " + formatMoney(amount) + ", new balance " + formatMoney(balance));
std::cout << "Withdrew " << formatMoney(amount) << ". New balance: " << formatMoney(balance) << "\n";
}
void Account::printHistory() const {
std::cout << "Transaction history for account #" << accountNumber << ":\n";
for (const std::string &entry : history) { // Range-based for loop over every recorded entry, in order
std::cout << " - " << entry << "\n";
}
}
Example Usage

Click Run to see what this code prints.

Step 4: Manage Multiple Accounts With a Bank Class

A single `Account` is not a banking system — the `Bank` class is what turns it into one, by owning a `std::vector<Account>` and a counter that hands out the next unused account number. `findAccount` returns an `Account*` pointing directly into the vector, so the caller can deposit or withdraw on it without searching a second time. One caveat worth knowing up front: because `std::vector` can reallocate its internal storage when it grows, a pointer returned by `findAccount` should be used immediately and not kept around across a later call to `createAccount` — the "Extend This Project" section below suggests a fix using `std::map` if that limitation ever matters for a larger version of this project.

class Bank {
private:
std::vector<Account> accounts; // Every account currently loaded into memory
int nextAccountNumber; // Auto-incrementing counter used to assign new account numbers
public:
Bank() : nextAccountNumber(1001) {} // First account created this run will be numbered 1001
Account* findAccount(int accNum); // Defined in this step
void createAccount(const std::string &name, double startingBalance); // Defined in this step
void listAccounts() const; // Defined in this step
void saveToFile(const std::string &filename) const; // Defined in Step 5
void loadFromFile(const std::string &filename); // Defined in Step 5
};
Account* Bank::findAccount(int accNum) { // Returns a pointer into the vector so callers can modify the account directly
for (Account &acc : accounts) { // Non-const reference so we can hand back a non-const pointer
if (acc.getAccountNumber() == accNum) {
return &acc; // Found it: hand back its address, not a copy
}
}
return nullptr; // No match; callers must check for this before dereferencing
}
void Bank::createAccount(const std::string &name, double startingBalance) {
accounts.push_back(Account(nextAccountNumber, name, startingBalance)); // Copy-construct a new Account into the vector
std::cout << "Account created! Your account number is " << nextAccountNumber << ".\n";
nextAccountNumber++; // Guarantees every future account gets a unique number
}
void Bank::listAccounts() const {
std::cout << "\nAccount#\tHolder\t\tBalance\n";
for (const Account &acc : accounts) {
std::cout << acc.getAccountNumber() << "\t\t" << acc.getHolderName()
<< "\t\t" << formatMoney(acc.getBalance()) << "\n";
}
}

Step 5: Persist Accounts to a File

`saveToFile` writes one comma-separated line per account, and `loadFromFile` reads those lines back with `std::getline` and `std::stringstream`, splitting each line on commas to recover the account number, name, and balance as their original types with `std::stoi` and `std::stod`. Loading also tracks the highest account number seen, so `nextAccountNumber` resumes correctly instead of restarting at 1001 and risking a collision with an account already on disk.

void Bank::saveToFile(const std::string &filename) const {
std::ofstream out(filename); // ofstream creates the file if missing, truncates it if it already exists
for (const Account &acc : accounts) {
// Comma-separated line: account number, holder name, balance
out << acc.getAccountNumber() << "," << acc.getHolderName() << "," << acc.getBalance() << "\n";
}
out.close(); // Flush and release the file handle
}
void Bank::loadFromFile(const std::string &filename) {
std::ifstream in(filename); // Attempt to open the saved accounts file
if (!in.is_open()) { // No file yet means this is the first run; nothing to load
return;
}
std::string line;
int highestSeen = 1000; // Tracks the largest account number found, so numbering resumes correctly
while (std::getline(in, line)) { // Read one comma-separated record per line
std::stringstream ss(line);
std::string numStr, name, balStr;
std::getline(ss, numStr, ','); // First field: account number
std::getline(ss, name, ','); // Second field: holder name
std::getline(ss, balStr, ','); // Third field: balance
int num = std::stoi(numStr); // Convert the text fields back into their real types
double bal = std::stod(balStr);
accounts.push_back(Account(num, name, bal));
if (num > highestSeen) {
highestSeen = num;
}
}
nextAccountNumber = highestSeen + 1; // Resume numbering after the highest account loaded from disk
in.close();
}

Step 6: Build the Menu Loop

`main` loads any existing accounts, then loops over a numbered menu until the user exits, saving to disk immediately after every change so no transaction is lost even if the program closes unexpectedly.

int main() {
Bank bank; // One Bank object owns every account for this run
const std::string filename = "accounts.txt";
bank.loadFromFile(filename); // Restore whatever accounts were saved from a previous run
int choice;
do {
std::cout << "\n===== BANK MANAGEMENT SYSTEM =====\n";
std::cout << "1. Create Account\n";
std::cout << "2. Deposit\n";
std::cout << "3. Withdraw\n";
std::cout << "4. Check Balance\n";
std::cout << "5. View Transaction History\n";
std::cout << "6. List All Accounts\n";
std::cout << "7. Exit\n";
std::cout << "Enter your choice: ";
std::cin >> choice;
if (choice == 1) {
std::string name;
double startingBalance;
std::cout << "Enter account holder name: ";
std::cin.ignore(); // Discard the leftover newline left behind by "std::cin >> choice"
std::getline(std::cin, name); // getline captures names containing spaces
std::cout << "Enter starting balance: ";
std::cin >> startingBalance;
if (startingBalance < 0) { // Defend against a negative opening balance
std::cout << "Starting balance cannot be negative. Setting balance to 0.\n";
startingBalance = 0;
}
bank.createAccount(name, startingBalance);
bank.saveToFile(filename); // Persist immediately so a new account survives an unexpected exit
} else if (choice == 2) {
int accNum;
double amount;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
std::cout << "Enter deposit amount: ";
std::cin >> amount;
acc->deposit(amount);
bank.saveToFile(filename);
}
} else if (choice == 3) {
int accNum;
double amount;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
std::cout << "Enter withdrawal amount: ";
std::cin >> amount;
acc->withdraw(amount);
bank.saveToFile(filename);
}
} else if (choice == 4) {
int accNum;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
std::cout << "Account #" << acc->getAccountNumber() << " (" << acc->getHolderName()
<< ") - Balance: " << formatMoney(acc->getBalance()) << "\n";
}
} else if (choice == 5) {
int accNum;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
acc->printHistory();
}
} else if (choice == 6) {
bank.listAccounts();
} else if (choice == 7) {
std::cout << "Goodbye!\n";
} else {
std::cout << "Invalid choice, try again.\n"; // Catches anything outside 1-7
}
} while (choice != 7); // Keep looping until the user explicitly picks Exit
return 0;
}

Complete Code

Here is the full program with every declaration and definition assembled in the correct order, ready to compile with `g++ bank.cpp -o bank`.

#include <iostream> // std::cin/std::cout for all console interaction
#include <fstream> // std::ifstream/std::ofstream for reading and writing account data to disk
#include <sstream> // std::stringstream/std::ostringstream for parsing and formatting text
#include <iomanip> // std::fixed/std::setprecision for two-decimal money formatting
#include <string> // std::string and std::to_string/std::stoi/std::stod
#include <vector> // std::vector to hold each account's transaction history and the bank's accounts
// Formats a monetary amount as a fixed two-decimal string, e.g. 1500 -> "1500.00"
std::string formatMoney(double amount) {
std::ostringstream oss;
oss << std::fixed << std::setprecision(2) << amount;
return oss.str();
}
class Account {
private:
int accountNumber; // Unique ID assigned by the Bank when the account is created
std::string holderName; // Account holder's name
double balance; // Current balance; private so it can only change via deposit()/withdraw()
std::vector<std::string> history; // Log of every transaction made on this account
public:
Account(int accNum, const std::string &name, double startingBalance);
int getAccountNumber() const { return accountNumber; }
std::string getHolderName() const { return holderName; }
double getBalance() const { return balance; }
void deposit(double amount);
void withdraw(double amount);
void printHistory() const;
};
Account::Account(int accNum, const std::string &name, double startingBalance)
: accountNumber(accNum), holderName(name), balance(startingBalance) {
history.push_back("Opened with balance " + formatMoney(startingBalance));
}
void Account::deposit(double amount) {
if (amount <= 0) {
std::cout << "Deposit amount must be positive.\n";
return;
}
balance += amount;
history.push_back("Deposited " + formatMoney(amount) + ", new balance " + formatMoney(balance));
std::cout << "Deposited " << formatMoney(amount) << ". New balance: " << formatMoney(balance) << "\n";
}
void Account::withdraw(double amount) {
if (amount <= 0) {
std::cout << "Withdrawal amount must be positive.\n";
return;
}
if (amount > balance) {
std::cout << "Insufficient funds. Current balance: " << formatMoney(balance) << "\n";
return;
}
balance -= amount;
history.push_back("Withdrew " + formatMoney(amount) + ", new balance " + formatMoney(balance));
std::cout << "Withdrew " << formatMoney(amount) << ". New balance: " << formatMoney(balance) << "\n";
}
void Account::printHistory() const {
std::cout << "Transaction history for account #" << accountNumber << ":\n";
for (const std::string &entry : history) {
std::cout << " - " << entry << "\n";
}
}
class Bank {
private:
std::vector<Account> accounts;
int nextAccountNumber;
public:
Bank() : nextAccountNumber(1001) {}
Account* findAccount(int accNum);
void createAccount(const std::string &name, double startingBalance);
void listAccounts() const;
void saveToFile(const std::string &filename) const;
void loadFromFile(const std::string &filename);
};
Account* Bank::findAccount(int accNum) {
for (Account &acc : accounts) {
if (acc.getAccountNumber() == accNum) {
return &acc;
}
}
return nullptr;
}
void Bank::createAccount(const std::string &name, double startingBalance) {
accounts.push_back(Account(nextAccountNumber, name, startingBalance));
std::cout << "Account created! Your account number is " << nextAccountNumber << ".\n";
nextAccountNumber++;
}
void Bank::listAccounts() const {
std::cout << "\nAccount#\tHolder\t\tBalance\n";
for (const Account &acc : accounts) {
std::cout << acc.getAccountNumber() << "\t\t" << acc.getHolderName()
<< "\t\t" << formatMoney(acc.getBalance()) << "\n";
}
}
void Bank::saveToFile(const std::string &filename) const {
std::ofstream out(filename);
for (const Account &acc : accounts) {
out << acc.getAccountNumber() << "," << acc.getHolderName() << "," << acc.getBalance() << "\n";
}
out.close();
}
void Bank::loadFromFile(const std::string &filename) {
std::ifstream in(filename);
if (!in.is_open()) {
return;
}
std::string line;
int highestSeen = 1000;
while (std::getline(in, line)) {
std::stringstream ss(line);
std::string numStr, name, balStr;
std::getline(ss, numStr, ',');
std::getline(ss, name, ',');
std::getline(ss, balStr, ',');
int num = std::stoi(numStr);
double bal = std::stod(balStr);
accounts.push_back(Account(num, name, bal));
if (num > highestSeen) {
highestSeen = num;
}
}
nextAccountNumber = highestSeen + 1;
in.close();
}
int main() {
Bank bank;
const std::string filename = "accounts.txt";
bank.loadFromFile(filename);
int choice;
do {
std::cout << "\n===== BANK MANAGEMENT SYSTEM =====\n";
std::cout << "1. Create Account\n";
std::cout << "2. Deposit\n";
std::cout << "3. Withdraw\n";
std::cout << "4. Check Balance\n";
std::cout << "5. View Transaction History\n";
std::cout << "6. List All Accounts\n";
std::cout << "7. Exit\n";
std::cout << "Enter your choice: ";
std::cin >> choice;
if (choice == 1) {
std::string name;
double startingBalance;
std::cout << "Enter account holder name: ";
std::cin.ignore();
std::getline(std::cin, name);
std::cout << "Enter starting balance: ";
std::cin >> startingBalance;
if (startingBalance < 0) {
std::cout << "Starting balance cannot be negative. Setting balance to 0.\n";
startingBalance = 0;
}
bank.createAccount(name, startingBalance);
bank.saveToFile(filename);
} else if (choice == 2) {
int accNum;
double amount;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
std::cout << "Enter deposit amount: ";
std::cin >> amount;
acc->deposit(amount);
bank.saveToFile(filename);
}
} else if (choice == 3) {
int accNum;
double amount;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
std::cout << "Enter withdrawal amount: ";
std::cin >> amount;
acc->withdraw(amount);
bank.saveToFile(filename);
}
} else if (choice == 4) {
int accNum;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
std::cout << "Account #" << acc->getAccountNumber() << " (" << acc->getHolderName()
<< ") - Balance: " << formatMoney(acc->getBalance()) << "\n";
}
} else if (choice == 5) {
int accNum;
std::cout << "Enter account number: ";
std::cin >> accNum;
Account *acc = bank.findAccount(accNum);
if (acc == nullptr) {
std::cout << "Account not found.\n";
} else {
acc->printHistory();
}
} else if (choice == 6) {
bank.listAccounts();
} else if (choice == 7) {
std::cout << "Goodbye!\n";
} else {
std::cout << "Invalid choice, try again.\n";
}
} while (choice != 7);
return 0;
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a PIN field to `Account` and require it to match before any deposit, withdrawal, or history lookup succeeds.
  • Write a `transferFunds(Account &from, Account &to, double amount)` function that withdraws from one account and deposits into another as one operation.
  • Switch `Bank`'s storage from `std::vector<Account>` to `std::map<int, Account>` keyed by account number, which avoids the pointer-invalidation caveat from Step 4 and makes lookups faster for a large bank.
  • Persist the full transaction history to its own file instead of only in memory, so `printHistory()` still works after the program restarts.
  • 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 management system that uses classes the way real C++ programs do: `Account` protects its own balance behind `private` fields and validated public methods, and `Bank` composes many accounts into one manageable collection. The declare-in-the-class, define-with-`ClassName::`-outside pattern you used throughout is the same one you will see in virtually every non-trivial C++ codebase, and the text-based file persistence you wrote will work correctly no matter what data types your classes end up containing.