A console-based banking system exploring the concepts of OOP and separation of concerns.
I wrote a console-based banking application to understand OOP principles and separation of concerns at a practical level. The system handles account creation, transaction processing, and role-based access control using a layered architecture that separates models, controllers, and repositories.
Having come from an SRC Architecture pattern, I knew to separate application concerns even though this was a small console application.
The application supports:
Key features:
The system uses a three-layer architecture adapted from MVC for console applications:
Represents domain entities and business logic. Models define data structure, encapsulate business rules, and implement core operations. They contain no knowledge of storage or presentation.
Key models:
Account.java - Abstract base class with common properties and operationsSavingsAccount.java / CheckingAccount.java - Concrete implementations with specific rulesCustomer.java - Customer and manager entities with role-based attributesTransaction.java - Transaction records with status trackingBusiness logic example from SavingsAccount:
@Override
public void withdraw(double amount) {
double currentBalance = checkBalance();
if (getAccountHolder().getCustomerType() == CustomerType.PREMIUM) {
super.withdraw(amount);
} else {
if ((currentBalance - amount) >= MIN_BALANCE) {
super.withdraw(amount);
} else {
System.out.println("Cannot withdraw: Would fall below minimum balance");
}
}
}
Handles user interaction and application flow. Controllers receive input, validate data, coordinate between repositories and models, and format output. They do not contain business logic.
Key controllers:
MenuController.java - Main application flow and navigationAccountController.java - Account creation and viewing workflowsTransactionController.java - Transaction processing workflowsController coordination example:
public void createAccount() {
// Get user input
System.out.print("Enter initial deposit: ");
double deposit = scanner.nextDouble();
// Create model (business logic here)
SavingsAccount account = new SavingsAccount(accountNumber, customer, deposit);
// Delegate storage to repository
accountManager.addAccount(account);
// Display result
System.out.println(account.getCreationMessage());
}
Manages data storage and retrieval. Repositories abstract storage implementation, provide CRUD operations, and handle data access logic. They do not contain business logic.
Key repositories:
AccountManager.java - Stores and retrieves accountsCustomerManager.java - Manages customer recordsTransactionManager.java - Maintains transaction historyRepository storage example:
public void addAccount(Account account) {
if (accountCount >= accounts.length) {
resizeArray(); // Infrastructure concern
}
accounts[accountCount++] = account; // Storage operation
}
Each layer has one responsibility:
Benefits:
Account is abstract because there is no generic account in real banking. You have savings accounts or checking accounts, never just an account. The abstract class forces all concrete implementations to define their own withdrawal logic.
Both SavingsAccount and CheckingAccount extend Account. They inherit common properties like account number, balance, and customer, but each implements its own rules.
Runtime behavior selection based on object type:
Account account = accountManager.findAccount(accountNumber);
account.withdraw(500); // Which withdraw() runs?
The JVM determines at runtime whether to call SavingsAccount.withdraw() or CheckingAccount.withdraw(). Same method call, different behavior.
All fields are private. You cannot directly modify an account's balance. You must use deposit() and withdraw(), which enforce business rules.
private double balance; // Cannot access directly
public void deposit(double amount) {
if (amount > 0) {
balance += amount; // Controlled access
}
}
Early on I initialized arrays inside methods. Every method call created a new array, wiping all stored data. The fix was understanding object lifecycle:
// Wrong
public void addAccount(Account account) {
Account[] accounts = new Account[50]; // New array every call
// ...
}
// Correct
public class AccountManager {
private Account[] accounts; // Instance variable
public AccountManager() {
this.accounts = new Account[50]; // Initialize once
}
}
Key insight:
Repositories use arrays with automatic capacity doubling:
private void resizeArray() {
Account[] newAccounts = new Account[accounts.length * 2];
System.arraycopy(accounts, 0, newAccounts, 0, accounts.length);
accounts = newAccounts;
}
Every transaction displays a preview before execution:
TRANSACTION CONFIRMATION
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Transaction ID: TXN1733001234567
Account: C414
Type: WITHDRAWAL
Amount: $500.00
Current Balance: $25,000.00
New Balance: $24,500.00
Date: 2025-11-30
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Confirm Transaction? (Y/N):
This preview-confirm pattern prevents accidental operations and improves user experience even in console applications.
Simple but effective authorization using ID prefixes:
if (!userId.startsWith("MGR")) {
System.out.println("ā Access Denied: Only managers can view all accounts.");
return;
}
Managers get IDs like MGR00001, customers get CUST00001. Not production-grade security, but demonstrates the authorization concept.
Initializing arrays inside methods created new instances on every call. All previous data was lost. Fixed by moving array initialization to constructors and using instance variables.
Originally made transactionId static, which caused all transactions to share the same ID. The most recent transaction ID would overwrite previous ones. Fixed by making it an instance variable so each transaction has its own unique ID.
Business rules belong in models, not controllers. Minimum balance checks go in SavingsAccount, not in AccountController. This separation makes the code testable and maintainable.
TransactionStatus enum into its own file for consistencyThis project covers the fundamentals: inheritance, polymorphism, encapsulation, abstraction, and architectural patterns. The layered approach makes the code maintainable and testable. The business logic is isolated in models, the UI flow is handled in controllers, and the storage is abstracted in repositories.
If you are learning OOP, build something like this. Make the mistakes. Debug the issues. Understanding comes from implementation, not from reading about principles.