LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.