The wallet that went negative
Riya's team built a canteen wallet for the college fest. Students top up, the canteen deducts. On day two, the report shows a student with a balance of Rs −4,800. Someone's test code had written wallet.balance = -4800; directly, and nothing stopped it.
Sir, our withdraw method checks for enough balance. How did the balance still go negative?
Because balance was public. Your check lives in withdraw(), but any code could skip the method and write the field directly. A rule that can be skipped is not a rule.
So I should make it private?
Yes. Make the data private and let the outside world touch it only through methods that check every change. That idea is called encapsulation, the first of the four pillars of OOP.
What is encapsulation?
Encapsulation is the OOP principle of bundling an object's data (fields) and the methods that operate on that data into a single unit (a class), and restricting direct access to the data. In Java this is done by declaring fields private and exposing controlled access through public methods such as getters (accessors) and setters (mutators), which can validate values and keep the object in a valid state. Hiding the data this way is also called data hiding.
An ATM, not an open cash box. You can't reach in and take notes; you press buttons, and the machine checks your PIN and balance before anything changes.
Words you'll meet in this lesson
private so other classes can't read or write them directly.getBalance(), isActive().setAge(int).final.Encapsulation at a glance
| Fields | private: visible only inside the class itself (Lesson 6.5) |
|---|---|
| Getter naming | getX(); for a boolean, isX() is also standard (isActive()) |
| Setter naming | void setX(Type x); it may reject bad values by throwing IllegalArgumentException |
| Read-only | Getter only, no setter. Mark the field final if it never changes after the constructor. |
| Immutable class | final class, private final fields, no setters, defensive copies of mutable parts. Example: String. |
| Checked when? | private access is checked by the compiler; validation inside setters runs at run time |
Exam tip: "data hiding" (private fields) is the means; encapsulation (data + methods in one unit, with controlled access) is the principle. A class with private fields and a public setter that accepts anything is hidden but not well encapsulated: the setter must protect the rule.
Three kinds of access you can design
Read-write
Getter and setter. The setter checks the value first: setPrice(-10) is rejected.
Read-only
Getter only. Account numbers, roll numbers and IDs are set once in the constructor.
get, final fieldImmutable
Nothing can change after creation. "Changing" it returns a new object, like String.
All three in one class
class Product {
private final String sku; // read-only
private double price; // read-write, validated
Product(String sku, double price) {
this.sku = sku;
setPrice(price); // reuse the same check
}
public String getSku() { return sku; }
public double getPrice() { return price; }
public void setPrice(double price) {
if (price <= 0) {
throw new IllegalArgumentException("Price must be positive: " + price);
}
this.price = price;
}
public double getPriceWithGst() { // computed, no field behind it
return price * 1.18;
}
}
public class Main {
public static void main(String[] args) {
Product p = new Product("TEA-250", 120);
p.setPrice(150);
System.out.println(p.getSku() + " costs Rs " + p.getPrice());
System.out.println("With 18% GST: Rs " + p.getPriceWithGst());
try {
p.setPrice(-10);
} catch (IllegalArgumentException e) {
System.out.println("Rejected: " + e.getMessage());
}
System.out.println("Price is still Rs " + p.getPrice());
}
}| Public field | Private field + methods | |
|---|---|---|
| Bad values | Anyone can store -5000 | The setter or method rejects them |
| Change the inside later | Every caller breaks | Callers keep calling the same methods |
| Read-only possible? | Only with final, and only for one value forever | Yes: give a getter, no setter |
| Logging, auditing a change | Impossible; writes are invisible | Add one line inside the method |
| Computed values | Must be stored and kept in sync | getPriceWithGst() computes on demand |
Classic mistake: a getter that returns a private array or list hands out a reference to the original. The caller can then change your private data without any setter. Return a copy (Arrays.copyOf, List.copyOf) instead. You'll see this in the animation and the worked examples.
Encapsulating a bank account
Make every field private
private final String accountNo; private double balance;privateis checked by javac. Code in another class that writesacc.balancedoesn't compile, so no bytecode that touches the field from outside can even exist.Validate in the constructor
The object is checked before anyone gets a reference to it. If the constructor throws, the caller never receives a half-valid object.BankAccount(String accountNo, double opening) { if (opening < 0) throw new IllegalArgumentException("Opening balance cannot be negative"); this.accountNo = accountNo; this.balance = opening; }Give getters for what others may read
For a primitive likepublic String getAccountNo() { return accountNo; } public double getBalance() { return balance; }double, the getter returns a copy of the value. The caller can change its copy freely; the field is untouched.Change data only through meaningful methods
Preferpublic boolean withdraw(double amount) { if (amount <= 0 || amount > balance) return false; balance -= amount; return true; }depositandwithdrawover a rawsetBalance. A bank never "sets" a balance; it records money in and money out. Each method is the one place where the rule is enforced.Protect mutable parts with copies
Arrays and lists are objects on the heap. Returning the field returns its address. Returning a copy gives the caller a separate object, so your private array stays private.public double[] getLastFive() { return Arrays.copyOf(lastFive, lastFive.length); }
Public field vs guarded methods: both views
Left: the line being run and what the console shows. Right: the account object on the heap and which "door" the change came through.
balance 6 steps- Public field: any class can write
acc.balance = -5000, and the program happily prints a negative balance. - Make it private: the same line now fails to compile:
balance has private access in BankAccount. - deposit(2000): the method checks the amount, then adds it. 10000 becomes 12000.
- withdraw(50000): more than the balance, so the method returns
falseand changes nothing. - deposit(-500): a negative deposit is a bug in the caller, so the method throws
IllegalArgumentException. - getBalance(): returns the value 12000.0. Changing
bafterwards doesn't touch the account.
- With a public field, the write goes straight from the caller to the object on the heap. No method runs, so no rule can be checked.
privateis enforced by the compiler. The class doesn't compile, so the bad write never reaches the JVM at all.- Calling
depositpushes a frame for it. The check passes, and the method updates the field on object #1. withdrawfinds the amount larger than the balance and returnsfalsebefore touching the field.deposit(-500)throws an exception from the check. The line that changes the balance is never reached.- A getter for a primitive copies the value onto the caller's stack.
b = 0changes only that copy.
Encapsulation in real systems
Every serious Java codebase keeps fields private. What differs is how much logic the methods hold.
Watch it with an example: a wallet that protects its balance
The balance field is private. Outside code can't touch it directly; it must go through withdraw(), which checks the rules first.
- The object: a Wallet with 500 in a
privatefield. Private means only code inside the Wallet class can read or write it. - Direct access:
w.balance = 1000000from another class doesn't even compile. Nobody can give themselves free money. - The safe door: outside code calls the
publicmethodwithdraw(200). The method checks the rule first: 200 is not more than 500. - Changed safely: the method itself updates balance to 300.
- Rule enforced:
withdraw(900)fails the check and throws an exception. The balance can never go negative. - Read access: a getter returns the value without exposing the field. Banking and wallet apps rely on this: every change goes through methods that validate and log it.
1. Banking and wallets
Account classes expose deposit, withdraw and transfer, never setBalance. Limits, minimum balance and freezing rules all live inside those methods, so no screen or batch job can skip them.
2. JavaBeans and frameworks
Frameworks that map objects to database rows, JSON or web forms commonly find properties through the getX/setX/isX naming convention. Following it lets those tools read and fill your objects.
3. Immutable value objects
Money, dates, coordinates and configuration are often immutable. They can be shared between threads and cached safely, because nobody can change them after creation. String and LocalDate work this way.
4. Safe sharing of internal lists
An order returns its line items as an unmodifiable list or a copy, so a reporting module that sorts or filters the result can't corrupt the order itself.
Examples are simplified. Real systems also check rules in the database and service layers, but the object guarding its own state is the first and cheapest line of defence.
Riya's wallet, encapsulated
class BankAccount {
private final String accountNo;
private double balance;
BankAccount(String accountNo, double opening) {
if (opening < 0) throw new IllegalArgumentException("Opening balance cannot be negative");
this.accountNo = accountNo;
this.balance = opening;
}
public String getAccountNo() { return accountNo; }
public double getBalance() { return balance; }
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive");
balance += amount;
}
public boolean withdraw(double amount) {
if (amount <= 0 || amount > balance) return false;
balance -= amount;
return true;
}
}
public class Main {
public static void main(String[] args) {
BankAccount acc = new BankAccount("SB-1001", 10000);
acc.deposit(2000);
System.out.println("After deposit: Rs " + acc.getBalance());
boolean ok = acc.withdraw(50000);
System.out.println("Withdraw 50000 allowed? " + ok);
System.out.println("Balance still: Rs " + acc.getBalance());
System.out.println("Account: " + acc.getAccountNo());
}
}private final String accountNo;Read-only: set once in the constructor, getter only, no setter.private double balance;Changed only bydepositandwithdraw, which check every request.throw new IllegalArgumentException(...)For a caller's mistake (negative deposit). Exceptions are covered in Module 9.return false;For a normal business "no" (not enough money). The caller decides what to show the user.
A wallet that refuses a bad top-up
Watch the add frame appear on top of main, check the amount, and either change the object or leave it alone.
Worked examples: real problems, solved and traced in memory
Three real jobs where an object must guard its own data. Run each solution, then step through it: watch method frames push and pop, and see that the private fields of each object (#1, #2 …) change only inside its own methods.
A setter that refuses impossible marks
Problem statement
A results portal lets teachers enter marks. A typo like 105 must never be saved. Write a Student class whose setMarks accepts only 0 to 100 and reports whether the value was saved.
Student "Aman"; setMarks(105), then setMarks(88)
false true 88
Approach
- Make
marksprivate so the setter is the only way to change it. - In
setMarks, check the range first and returnfalsewithout touching the field if it fails. - Otherwise store the value and return
true. - Read the final value through
getMarks().
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
A wallet with a daily spending limit
Problem statement
A student wallet allows at most Rs 2,000 of payments per day. pay(amount) must refuse a payment if the balance is too low or if it would cross the daily limit. Pay Rs 1,500 and then Rs 800, starting with Rs 5,000.
balance 5000, daily limit 2000; pay 1500, pay 800
Pay 1500: true Pay 800: false Balance: 3500
Approach
- Keep
balanceandspentTodayprivate: both are changed together by one method. paychecks both rules before changing anything.- If allowed, update both fields; the object can never be half-updated.
- Expose only getters for reading.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
A defensive copy keeps the order safe
Problem statement
An Order is created from an array of item quantities. Later, the cart screen reuses its array for the next customer and sets it to zeros. The placed order must keep its original quantities.
cart = {2, 1}; order created; then cart[0] = 0Order items: 3
Approach
- In the constructor, store a copy of the array, not the caller's array.
- The caller's later change hits only its own array.
- Compute the item count from the order's private copy.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
Check yourself
Quick revision
Lesson 7.1 in one screen
- Encapsulation = data + methods in one class, with the data hidden behind those methods.
- Fields
private; access throughgetX()/isX()andsetX()or business methods. - Setters and constructors validate; bad values are rejected (exception or
false). - Read-only: getter, no setter, field often
final. - Immutable:
finalclass,private finalfields, no setters, "changes" return new objects. - Never return or store a private array/list directly: make a defensive copy.
privateis checked at compile time; validation runs at run time.
Interview and viva questions
What is the difference between encapsulation and data hiding?
Data hiding is making fields inaccessible from outside (with private). Encapsulation is the bigger idea: keeping data and the methods that work on it together in one class, and allowing changes only through those methods so the object stays valid. Data hiding is how Java achieves encapsulation.
If a class has a getter and a setter for every field, is it encapsulated?
Only partly. If the setters accept any value, the object can still be put into an invalid state; you've only added ceremony. Good encapsulation exposes meaningful operations (deposit, withdraw) and validates, and leaves out setters that shouldn't exist.
Why are getters and setters better than public fields?
They let the class validate changes, make some properties read-only, compute values on demand, log or audit changes, and change the internal representation later without breaking callers. A public field allows none of that.
How do you create an immutable class in Java?
Declare the class final (so subclasses can't add mutable behaviour), make all fields private final, set them in the constructor, provide no setters, take defensive copies of mutable inputs and return copies (or unmodifiable views) of mutable fields. "Modifying" methods return a new object.
What is a defensive copy and when do you need one?
A copy of a mutable object made when it enters or leaves your class. You need one when a field holds an array, a list, a Date or any other mutable object: otherwise the caller who passed it in, or who received it from a getter, still holds a reference and can change your private state.
Practice problems
Each problem asks you to design a small class that protects its own data. Decide first which fields are private, which get getters, which get setters (if any) and what each method must check. The last three are business problems.
Student with private fields
Create a Student class with private name and marks, a constructor, and getters. Print the student's details from main using only the getters.
name = "Riya", marks = 86
Riya scored 86
Show solution and explanation
class Student {
private String name;
private int marks;
Student(String name, int marks) {
this.name = name;
this.marks = marks;
}
public String getName() { return name; }
public int getMarks() { return marks; }
}
public class Main {
public static void main(String[] args) {
Student s = new Student("Riya", 86);
System.out.println(s.getName() + " scored " + s.getMarks());
}
}How it works: the fields are private, so main cannot write s.marks. It reads the values through the getters. The constructor is the only place the values are set, which makes both of them effectively read-only.
Setter that rejects bad marks
Add setMarks(int) to a Student class. Marks must be between 0 and 100; otherwise print a message and keep the old value. Try 92, then 140.
setMarks(92), then setMarks(140)
Marks: 92 Rejected: 140 is not between 0 and 100 Marks: 92
Show solution and explanation
class Student {
private int marks;
public void setMarks(int marks) {
if (marks < 0 || marks > 100) {
System.out.println("Rejected: " + marks + " is not between 0 and 100");
return;
}
this.marks = marks;
}
public int getMarks() { return marks; }
}
public class Main {
public static void main(String[] args) {
Student s = new Student();
s.setMarks(92);
System.out.println("Marks: " + s.getMarks());
s.setMarks(140);
System.out.println("Marks: " + s.getMarks());
}
}How it works: the setter is the only door to the field, so the range check runs for every change. return leaves the method before the assignment, so 140 never reaches the field. (In larger programs you'd usually throw an exception instead of printing; see Module 9.)
Read-only roll number
A Student has a roll number that must never change after admission and a phone number that may change. Design the class so the roll number has no setter and is final.
roll = "CS-042", phone = "98200 11111" then "98200 22222"
CS-042 -> 98200 22222
Show solution and explanation
class Student {
private final String rollNo;
private String phone;
Student(String rollNo, String phone) {
this.rollNo = rollNo;
this.phone = phone;
}
public String getRollNo() { return rollNo; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
}
public class Main {
public static void main(String[] args) {
Student s = new Student("CS-042", "98200 11111");
s.setPhone("98200 22222");
System.out.println(s.getRollNo() + " -> " + s.getPhone());
}
}How it works: rollNo has a getter only, so other classes can't change it, and final means even the class's own methods can't reassign it after the constructor. phone is read-write. Try adding rollNo = "X"; inside setPhone: javac refuses.
Computed property: Celsius and Fahrenheit
Store a temperature only in Celsius (private). Provide getCelsius(), getFahrenheit() (computed) and setCelsius() that rejects values below absolute zero (−273.15).
setCelsius(37), then setCelsius(-300)
37.0 C = 98.6 F Below absolute zero, ignored: -300.0 Still 37.0 C
Show solution and explanation
class Temperature {
private double celsius;
public void setCelsius(double c) {
if (c < -273.15) {
System.out.println("Below absolute zero, ignored: " + c);
return;
}
celsius = c;
}
public double getCelsius() { return celsius; }
public double getFahrenheit() { return celsius * 9 / 5 + 32; }
}
public class Main {
public static void main(String[] args) {
Temperature t = new Temperature();
t.setCelsius(37);
System.out.println(t.getCelsius() + " C = " + t.getFahrenheit() + " F");
t.setCelsius(-300);
System.out.println("Still " + t.getCelsius() + " C");
}
}How it works: only one value is stored, so Celsius and Fahrenheit can never disagree. getFahrenheit() looks like a normal getter to callers, but computes its answer. That's a benefit of methods over public fields: the inside can change without callers noticing.
Account with a minimum balance
A savings account must always keep at least Rs 1,000. withdraw returns true if allowed, false otherwise. Start with Rs 5,000, then try to withdraw 3,500 and then 1,000.
opening 5000, withdraw 3500, withdraw 1000
Withdraw 3500: true, balance 1500.0 Withdraw 1000: false, balance 1500.0
Show solution and explanation
class SavingsAccount {
private static final double MIN_BALANCE = 1000;
private double balance;
SavingsAccount(double opening) {
balance = opening;
}
public boolean withdraw(double amount) {
if (amount <= 0 || balance - amount < MIN_BALANCE) {
return false;
}
balance -= amount;
return true;
}
public double getBalance() { return balance; }
}
public class Main {
public static void main(String[] args) {
SavingsAccount acc = new SavingsAccount(5000);
System.out.println("Withdraw 3500: " + acc.withdraw(3500) + ", balance " + acc.getBalance());
System.out.println("Withdraw 1000: " + acc.withdraw(1000) + ", balance " + acc.getBalance());
}
}How it works: after the first withdrawal, 1,500 is left. The second would leave 500, below the minimum, so it's refused and the balance stays 1,500. The rule is written once, inside the class; no caller can forget it.
Write-only PIN with a check method
An ATM card stores a 4-digit PIN. There must be no getter for the PIN. Provide changePin(old, new) and verify(pin). A new PIN must have exactly 4 digits.
initial PIN "4321"; verify "1111"; changePin("4321", "98"); changePin("4321", "2580"); verify "2580"verify 1111: false change to 98: false change to 2580: true verify 2580: true
Show solution and explanation
class AtmCard {
private String pin;
AtmCard(String pin) {
this.pin = pin;
}
public boolean verify(String attempt) {
return pin.equals(attempt);
}
public boolean changePin(String oldPin, String newPin) {
if (!verify(oldPin)) return false;
if (newPin.length() != 4 || !newPin.chars().allMatch(Character::isDigit)) return false;
pin = newPin;
return true;
}
}
public class Main {
public static void main(String[] args) {
AtmCard card = new AtmCard("4321");
System.out.println("verify 1111: " + card.verify("1111"));
System.out.println("change to 98: " + card.changePin("4321", "98"));
System.out.println("change to 2580: " + card.changePin("4321", "2580"));
System.out.println("verify 2580: " + card.verify("2580"));
}
}How it works: encapsulation doesn't mean “a getter and setter for everything”. The PIN can be checked but never read. chars().allMatch(Character::isDigit) checks every character is a digit (streams are covered in Module 14; a simple loop works too).
Defensive copy of a marks array
A ReportCard receives an int[] of marks. Make sure that changing the caller's array later, or changing the array returned by the getter, does not change the report card.
marks = {70, 85, 90}; caller sets marks[0] = 0; getter result [1] set to 0input: [0, 85, 90] got: [70, 0, 90] stored: [70, 85, 90]
Show solution and explanation
import java.util.Arrays;
class ReportCard {
private final int[] marks;
ReportCard(int[] marks) {
this.marks = Arrays.copyOf(marks, marks.length);
}
public int[] getMarks() {
return Arrays.copyOf(marks, marks.length);
}
}
public class Main {
public static void main(String[] args) {
int[] input = {70, 85, 90};
ReportCard rc = new ReportCard(input);
input[0] = 0;
int[] got = rc.getMarks();
got[1] = 0;
System.out.println("input: " + Arrays.toString(input));
System.out.println("got: " + Arrays.toString(got));
System.out.println("stored: " + Arrays.toString(rc.getMarks()));
}
}How it works: copies are taken at both doors: on the way in (constructor) and on the way out (getter). Three separate arrays exist, so the two changes hit only the outside copies. final on the field stops reassigning it, but would not stop changing the elements; only the copies do that.
GST invoice line
An invoice line has a product name, unit price and quantity. Price and quantity must be positive. Provide computed getters for the taxable amount, GST (18%) and the line total. Reject a quantity of 0.
"Office chair", price 4500, qty 3; then setQuantity(0)
Office chair Taxable Rs 13500.00, GST Rs 2430.00, Total Rs 15930.00 Rejected: Quantity must be at least 1 Total still Rs 15930.00
Show solution and explanation
class InvoiceLine {
private final String product;
private final double unitPrice;
private int quantity;
InvoiceLine(String product, double unitPrice, int quantity) {
if (unitPrice <= 0) throw new IllegalArgumentException("Bad price");
this.product = product;
this.unitPrice = unitPrice;
setQuantity(quantity);
}
public void setQuantity(int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be at least 1");
this.quantity = quantity;
}
public double getTaxable() { return unitPrice * quantity; }
public double getGst() { return getTaxable() * 0.18; }
public double getTotal() { return getTaxable() + getGst(); }
public String getProduct() { return product; }
}
public class Main {
public static void main(String[] args) {
InvoiceLine line = new InvoiceLine("Office chair", 4500, 3);
System.out.println(line.getProduct());
System.out.printf("Taxable Rs %.2f, GST Rs %.2f, Total Rs %.2f%n",
line.getTaxable(), line.getGst(), line.getTotal());
try {
line.setQuantity(0);
} catch (IllegalArgumentException e) {
System.out.println("Rejected: " + e.getMessage());
}
System.out.printf("Total still Rs %.2f%n", line.getTotal());
}
}How it works:
- Only price and quantity are stored. Taxable, GST and total are computed, so they can never go out of sync after a quantity change.
- The constructor calls
setQuantity, so the same check guards both creation and later changes. - 4500 × 3 = 13,500; GST 2,430; total 15,930.
Inventory item that can't go below zero
A warehouse item tracks stock. receive(n) adds stock; dispatch(n) removes it only if enough is available and returns whether it succeeded. Also count how many dispatches were refused, readable via a getter.
start 20; dispatch 15; dispatch 10; receive 30; dispatch 10
dispatch 15: true, stock 5 dispatch 10: false, stock 5 received 30, stock 35 dispatch 10: true, stock 25 RICE-25KG refused dispatches: 1
Show solution and explanation
class StockItem {
private final String sku;
private int stock;
private int refused;
StockItem(String sku, int stock) {
this.sku = sku;
this.stock = stock;
}
public void receive(int qty) {
if (qty > 0) stock += qty;
}
public boolean dispatch(int qty) {
if (qty <= 0 || qty > stock) {
refused++;
return false;
}
stock -= qty;
return true;
}
public int getStock() { return stock; }
public int getRefused() { return refused; }
public String getSku() { return sku; }
}
public class Main {
public static void main(String[] args) {
StockItem rice = new StockItem("RICE-25KG", 20);
System.out.println("dispatch 15: " + rice.dispatch(15) + ", stock " + rice.getStock());
System.out.println("dispatch 10: " + rice.dispatch(10) + ", stock " + rice.getStock());
rice.receive(30);
System.out.println("received 30, stock " + rice.getStock());
System.out.println("dispatch 10: " + rice.dispatch(10) + ", stock " + rice.getStock());
System.out.println(rice.getSku() + " refused dispatches: " + rice.getRefused());
}
}How it works:
refusedis internal bookkeeping: it has a getter but no setter, so only the class can count.- Because
stockchanges only inreceiveanddispatch, the rule “stock is never negative” holds for every object, always. - Stock goes 20 → 5 → (refused) → 35 → 25.
Immutable money transfer request
A UPI transfer request has a sender, receiver and amount in rupees. It must be immutable: no setters, and a method withAmount that returns a new request with a different amount. Amount must be between Rs 1 and Rs 1,00,000.
from "riya@upi" to "canteen@upi", 250; then withAmount(300)
riya@upi -> canteen@upi : Rs 250 riya@upi -> canteen@upi : Rs 300 Same object? false
Show solution and explanation
final class TransferRequest {
private final String from;
private final String to;
private final long amount;
TransferRequest(String from, String to, long amount) {
if (amount < 1 || amount > 100_000) {
throw new IllegalArgumentException("Amount out of range: " + amount);
}
this.from = from;
this.to = to;
this.amount = amount;
}
public TransferRequest withAmount(long newAmount) {
return new TransferRequest(from, to, newAmount);
}
public String describe() {
return from + " -> " + to + " : Rs " + amount;
}
}
public class Main {
public static void main(String[] args) {
TransferRequest r1 = new TransferRequest("riya@upi", "canteen@upi", 250);
TransferRequest r2 = r1.withAmount(300);
System.out.println(r1.describe());
System.out.println(r2.describe());
System.out.println("Same object? " + (r1 == r2));
}
}How it works:
final classstops anyone from writing a subclass that adds setters.private finalfields can't be changed after the constructor.withAmountbuilds a new object through the same constructor, so the range check runs again.- r1 is untouched, which makes such requests safe to log, retry or share between threads.
Real-world scenario questions (asked in company interviews)
Most data-corruption bugs start with state that anyone could change. Decide your answer first, then open the model answer.
The overdraft nobody approved
A savings account class has public double balance and a withdraw() method that checks the minimum balance of Rs 1,000. An audit finds accounts at Rs −2,300. The withdraw code looks correct. Where would you look, and how would you fix it?
The interviewer is checking: do you see that a public field lets code bypass the rule?
Show a strong answer
- Search for direct writes to
balance(for example a reversal job doingacc.balance -= fee). Those skipwithdraw()entirely. - Make the field
private. The compiler will then list every place that writes it, and each must be changed to call a method. - Add methods for the real operations (
chargeFee,reverse) that apply the same rules, and add a test that a negative balance is impossible.
Avoid: adding the same check in every caller. The rule belongs in one place: the account class.
The report that changed real orders
An Order class keeps private List<Item> items and has getItems() that returns it. A new analytics feature removes cancelled items from the list it receives before calculating totals. Customers start getting invoices with missing items. Explain the bug and the fix.
The interviewer is checking: do you understand reference leaks through getters?
Show a strong answer
privateprotected the variable, not the list object. The getter returned a reference to the same list, so the analytics code removed items from the real order.- Return
List.copyOf(items)(Java 10+, unmodifiable) orCollections.unmodifiableList(items). Callers that try to modify it get an exception instead of silently corrupting data. - Also copy the list passed into the constructor, and offer methods like
addItemthat validate.
Avoid: "the analytics team should be careful". The class must protect its own state.
"The IDE generated all the setters"
In a subscription billing service, a teammate generated getters and setters for every field of Subscription, including setStatus, setPaidUntil and setPlanPrice. Support staff tools now call setPaidUntil directly to "fix" accounts. What would you change?
The interviewer is checking: can you design meaningful methods instead of blind setters?
Show a strong answer
- Remove setters that let callers skip business rules. Replace them with operations:
renew(Payment p),cancel(String reason),changePlan(Plan newPlan). - Each operation updates several fields together and validates (a renewal needs a successful payment; the paid-until date moves forward only).
- Support fixes should go through an audited method such as
grantExtension(days, approvedBy).
Avoid: treating "private fields + public setters for all" as encapsulation. It's a public field with extra steps.
A shared rate object changed mid-calculation
A loan pricing service caches one RateCard object (with setters) and shares it across all request threads. An admin screen updates rates by calling setters on that object. Occasionally a customer's EMI is calculated with half old and half new rates. How would you redesign it?
The interviewer is checking: do you know why immutability makes shared data safe?
Show a strong answer
- Make
RateCardimmutable:finalclass,private finalfields, no setters, defensive copies of any maps or arrays. - To change rates, build a new RateCard and swap the shared reference in one step (for example a
volatilefield or anAtomicReference, Module 12). - Each calculation reads the reference once at the start, so it sees one consistent RateCard from start to finish.
Avoid: adding locks around every getter. Immutability removes the problem instead of managing it.