OOP As A Design Tool
You’ve written variables, loops, and methods. But real systems — a payments backend, a booking engine, anything you’d get paid to build — aren’t 500-line scripts. They’re hundreds of small pieces that each own one job. OOP is how Java carves a big problem into those pieces. This module is where Java stops being “syntax” and starts being “design.”
The Goal
By the end of this module you can:
- Explain the difference between a class and an object, including what actually sits in memory
- Design a class with private fields, constructors, and deliberate getters — not auto-generated noise
- Choose between inheritance and composition, and defend the choice out loud
- Use interfaces and polymorphism to write code that handles UPI, cards, and net banking without an if-else jungle
- Spot why
==lies for objects and whatequalsandhashCodeare really for
The Lesson
Class vs object — blueprint vs instance
A class is a blueprint. An object is a thing built from that blueprint. One class, as many objects as you want — each with its own data.
public class BankAccount {
private String owner;
private double balance;
public BankAccount(String owner, double balance) {
this.owner = owner;
this.balance = balance;
}
}
BankAccount a1 = new BankAccount("Ravi", 5000.0);
BankAccount a2 = new BankAccount("Priya", 12000.0);
Here’s what memory looks like after those two lines. The variables a1 and a2 live on the stack and hold references — arrows pointing into the heap, where the actual objects sit:
flowchart LR
subgraph Stack
A1[a1 reference]
A2[a2 reference]
end
subgraph Heap
O1[BankAccount object 1 - owner Ravi - balance 5000]
O2[BankAccount object 2 - owner Priya - balance 12000]
end
A1 --> O1
A2 --> O2
One blueprint, two separate objects, two separate balances. Change a1’s balance and a2 doesn’t care. That separation is the entire point.
Why this matters in a real job: every user session, every payment, every order in a production system is an object built from one class. If you don’t get blueprint-vs-instance, every bug involving “why did changing X also change Y” will confuse you.
Constructors and this
A constructor runs once, when the object is born. Its job: make sure the object never exists in a half-built, invalid state.
public BankAccount(String owner, double balance) {
// 'this.owner' is the object's field; 'owner' is the parameter.
// Without 'this', Java would assign the parameter to itself — silently doing nothing.
this.owner = owner;
this.balance = balance;
}
this means “the object currently being worked on.” You need it when a parameter name shadows a field name — which is the standard convention, so you’ll write this.x = x constantly.
A constructor is also your first gatekeeper:
public BankAccount(String owner, double balance) {
if (balance < 0) {
throw new IllegalArgumentException("Balance cannot be negative");
}
this.owner = owner;
this.balance = balance;
}
Now a BankAccount with -500 rupees cannot exist. That’s design, not syntax.
Encapsulation — getters are a decision, not boilerplate
Fields are private. Always start there. The question is what you expose, and tutorials get this badly wrong by telling you to generate a getter and setter for everything. That’s just making the field public with extra steps.
The real rule: expose only what callers legitimately need, in the form they need it.
public class BankAccount {
private String owner;
private double balance;
public BankAccount(String owner, double balance) {
this.owner = owner;
this.balance = balance;
}
// Getter: yes — callers legitimately need to see the balance.
public double getBalance() {
return balance;
}
// Setter for balance: NO. A balance changes through business
// operations, never by someone overwriting it directly.
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive");
balance += amount;
}
public void withdraw(double amount) {
if (amount > balance) throw new IllegalArgumentException("Insufficient funds");
balance -= amount;
}
}
setBalance(double b) would let any code anywhere write account.setBalance(-99999). deposit and withdraw enforce the rules inside the class. That’s encapsulation: the class guards its own data and the rest of the codebase physically cannot corrupt it.
| Auto-generated style | Designed style |
|---|---|
| Getter + setter for every field | Getter only if callers need to read it |
setBalance(x) | deposit(x) / withdraw(x) with validation |
| Rules scattered across the codebase | Rules live in one place — the class |
| Any code can break the object | The object cannot be put in an invalid state |
Static vs instance — the classic beginner trap
- Instance members: one copy per object.
balanceis instance — every account has its own. - Static members: one copy per class, shared by all objects. Good for counters, constants, utility methods.
public class BankAccount {
private static int totalAccounts = 0; // ONE counter, shared
private double balance; // one balance PER account
public BankAccount(double balance) {
this.balance = balance;
totalAccounts++; // every birth bumps the shared counter
}
public static int getTotalAccounts() {
return totalAccounts;
}
}
flowchart TD
C[BankAccount class - totalAccounts 2 - one copy shared]
O1[Object 1 - balance 5000]
O2[Object 2 - balance 12000]
C --- O1
C --- O2
The trap: marking something static because the compiler complained (“non-static method cannot be referenced from a static context” inside main). Don’t silence the compiler — understand it. main is static because it runs before any object exists, so it can’t touch instance members directly. The fix is usually to create an object in main and call methods on it, not to make everything static.
If everything in your class is static, you don’t have objects — you have a script wearing a class costume.
Inheritance — what it is, and when it’s the wrong tool
Inheritance: a subclass is a specialized version of its parent, getting the parent’s fields and methods for free.
public class Payment {
protected double amount;
public Payment(double amount) { this.amount = amount; }
public void process() {
System.out.println("Processing payment of " + amount + " rupees");
}
}
public class UpiPayment extends Payment {
private String upiId;
public UpiPayment(double amount, String upiId) {
super(amount); // call the parent constructor first
this.upiId = upiId;
}
@Override
public void process() {
System.out.println("UPI payment of " + amount + " rupees via " + upiId);
}
}
Looks clean. But here’s the blunt truth: inheritance is the most overused tool in OOP. It welds the child to the parent — every change in Payment ripples into every subclass forever. And it locks each class into exactly one parent. What happens when CardPayment needs to share retry logic with NetBankingPayment but not UpiPayment? You start contorting the hierarchy, and it never recovers.
Use inheritance only when the relationship is genuinely, permanently is-a, and the parent was designed to be extended. That’s rarer than tutorials suggest.
Composition — has-a, and why real code prefers it
Composition: instead of being a thing, your class has a thing.
// A Payment HAS a payment method. The method is a part, swappable at will.
public class Payment {
private final double amount;
private final PaymentMethod method; // composition: has-a
public Payment(double amount, PaymentMethod method) {
this.amount = amount;
this.method = method;
}
public void process() {
method.pay(amount); // delegate the work to the part
}
}
Why real codebases prefer this:
| Inheritance | Composition | |
|---|---|---|
| Relationship | is-a, fixed at compile time | has-a, chosen at runtime |
| Swap behavior later? | No — your parent is your parent | Yes — hand in a different part |
| Coupling | Tight — parent changes break children | Loose — only the contract matters |
| Combine behaviors? | One parent max | Hold as many parts as you need |
The rule of thumb you’ll hear in code reviews for the rest of your career: favor composition over inheritance. Reach for inheritance only when composition genuinely can’t express it.
Interfaces — contracts, not implementations
An interface says what must be doable, with zero opinion on how:
public interface PaymentMethod {
void pay(double amount); // the contract: every method can pay an amount
}
Any class that implements PaymentMethod promises to provide pay. The caller stops caring which class it’s talking to — only that the contract is honored. This is the same idea as a wall socket: the socket doesn’t care if you plug in a phone charger or a lamp, as long as the plug fits.
Polymorphism — one reference type, many runtime shapes
This is where the previous two ideas pay off. Full runnable example:
interface PaymentMethod {
void pay(double amount);
}
class UpiPayment implements PaymentMethod {
private final String upiId;
UpiPayment(String upiId) { this.upiId = upiId; }
@Override
public void pay(double amount) {
System.out.println("UPI: " + amount + " rupees sent via " + upiId);
}
}
class CardPayment implements PaymentMethod {
private final String last4;
CardPayment(String last4) { this.last4 = last4; }
@Override
public void pay(double amount) {
System.out.println("Card ending " + last4 + ": charged " + amount + " rupees");
}
}
class NetBankingPayment implements PaymentMethod {
private final String bank;
NetBankingPayment(String bank) { this.bank = bank; }
@Override
public void pay(double amount) {
System.out.println("NetBanking via " + bank + ": " + amount + " rupees transferred");
}
}
public class Checkout {
public static void main(String[] args) {
// One reference type — PaymentMethod — three different runtime shapes.
PaymentMethod[] methods = {
new UpiPayment("darshan@upi"),
new CardPayment("4242"),
new NetBankingPayment("SBI")
};
for (PaymentMethod m : methods) {
m.pay(499.0); // Java picks the right pay at RUNTIME
}
}
}
The loop has no idea which payment type it’s holding. Java looks at the actual object at runtime and dispatches to the right pay. Add WalletPayment tomorrow — the loop doesn’t change. Compare that with the alternative: an if (type.equals("upi")) ... else if (type.equals("card")) ... chain that you must hunt down and edit in fifteen places every time a payment type is added. Polymorphism deletes that chain.
flowchart TD
R[PaymentMethod reference] --> Q{Actual object at runtime}
Q --> U[UpiPayment pay runs]
Q --> C[CardPayment pay runs]
Q --> N[NetBankingPayment pay runs]
toString, equals, hashCode — why == lies
Three methods every object inherits from Object, and why you’ll override them:
toString— what printing the object shows. Default is garbage likePayment@6f2b958e. Override it to print something useful.equals—==on objects compares references (are these the same arrow into the heap?), not contents. Twonew UpiPayment("a@upi")objects are==-different even though they’re logically identical. Overrideequalsto define what “same” means for your class.hashCode— a number used by hash-based collections to find objects fast. Rule: if two objects areequals, they must have the samehashCode. Break that rule andHashSet/HashMap(next module) silently misbehave.
String s1 = new String("upi");
String s2 = new String("upi");
System.out.println(s1 == s2); // false — different heap objects
System.out.println(s1.equals(s2)); // true — same contents
Burn this in now: == for primitives, equals for objects. This single line of knowledge prevents a whole category of bugs that AI-generated code happily ships.
Build This
Design the payment system twice and feel the difference yourself.
- Make a folder
oop-paymentsand inside it createInheritanceVersion.java. Build: an abstract-ish parentclass Paymentwith anamountfield and aprocess()method, thenUpiPayment,CardPayment,NetBankingPaymenteach extendingPaymentand overridingprocess(). Inmain, put one of each in aPayment[]and loop-process them. - Compile and run from a plain terminal:
javac InheritanceVersion.java java InheritanceVersion - Now create
CompositionVersion.java. Build: aPaymentMethodinterface withvoid pay(double amount), three classes implementing it, and aPaymentclass that has aPaymentMethodfield plus anamount, withprocess()delegating tomethod.pay(amount). Inmain, create threePaymentobjects, each composed with a different method, and process them. - Compile and run it the same way.
- Now the stress test: in both versions, add a fourth payment type,
WalletPayment. Count how many lines you touched in each version. - Final step — open a file
reflection.txtand write 3 lines: which version felt more flexible, why, and which one you’d bet on if payment types kept multiplying. No right answer accepted without a reason.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| dev.java | Read the OOP section of Learn Java — classes, interfaces, inheritance | 30 min |
| exercism.org Java track | Do 2-3 exercises tagged with classes or interfaces | 45 min |
| codingbat.com/java | Warm-up logic drills to keep syntax sharp while OOP sinks in | 20 min |
| HackerRank Java | The Java Inheritance and Java Interface challenges | 30 min |
How to Practice
- Type every line yourself. Never copy-paste — your fingers are part of your memory.
- Time-box: 25 minutes building, 5 minutes reading. Not the other way round.
- Break it on purpose: remove
@Overrideand misspell a method name, watch what happens. Make a field public and corrupt it frommain. Feel whyprivateexists. - After each session, explain one concept out loud as if to a friend. If you can’t, you don’t have it yet.
Check Yourself
- What’s the difference between a class and an object, in memory terms?
- Why does
this.owner = ownerneed thethis? - Why is auto-generating a setter for every field a design mistake?
- A
staticfield with two objects of the class — how many copies of that field exist? - When is inheritance the wrong tool, and what do you reach for instead?
- In the checkout loop, how does Java know which
paymethod to run? - Why does
==return false for two objects with identical contents? - If you override
equals, why must you also overridehashCode?
Answers
- A class is the blueprint (loaded once); an object is an instance built from it on the heap. Variables hold references — arrows pointing at heap objects, not the objects themselves.
- The parameter
ownershadows the fieldowner.this.ownersays “the field on this object”; without it, you’d assign the parameter to itself and the field stays null. - A setter on everything is just a public field with ceremony — any code can put the object into an invalid state. Expose business operations (
deposit,withdraw) that enforce rules instead. - One. Static means one copy per class, shared by every object. Instance fields get one copy per object.
- Wrong when the relationship isn’t genuinely is-a, when you’d need behavior from more than one parent, or when behavior should be swappable at runtime. Reach for composition — hold the behavior as a field behind an interface.
- At runtime, Java checks the actual object’s type (not the reference type) and dispatches to that class’s override. That’s dynamic dispatch — the engine behind polymorphism.
==compares references — whether both variables point at the exact same heap object. Two separately created objects are different addresses, so==is false even with identical contents. Useequals.- Hash collections find objects by hashCode first, then confirm with equals. If equal objects produce different hash codes, a
HashSetcan hold “duplicates” and aHashMapcan lose your entries.
Explain it out loud: without looking at the code, explain to an imaginary junior why the checkout loop doesn’t need to know which payment type it’s processing — and what would break in a codebase that used if-else chains instead.
Still Unclear?
Copy-paste any of these into Claude:
- “Draw out what the stack and heap look like, step by step, when I run
BankAccount a = new BankAccount("Ravi", 5000); BankAccount b = a; b.deposit(100);— and tell me whata.getBalance()returns and why.” - “Give me a real-world Java example where inheritance was the wrong choice and composition fixed it. Show both versions of the code and explain what specifically broke in the inheritance version.”
- “I wrote a class where two objects with the same field values are not equal in a HashSet. Walk me through how equals and hashCode interact inside HashSet, and show me a correct override for a class with two fields.”
Why AI Can’t Do This For You
AI will happily generate classes, getters, and inheritance trees — including bad ones. It can’t tell you whether your domain needs is-a or has-a, because that’s a judgment about how your system will change over the next two years. The engineer who can look at a class hierarchy and say “this will hurt us, flatten it to composition” is the one in the design meeting. The one who just accepts generated code is replaceable by the generator.
Module done? Add it to today’s tracker