OOP Recap & UML
A hands-on refresher on encapsulation, inheritance, abstraction, polymorphism, and composition, plus the UML notation used to diagram class relationships throughout this course.
Learning objectives
- Explain encapsulation, inheritance, abstraction, and polymorphism using one working example
- Decide whether a design problem calls for inheritance or for an interface
- Read and sketch the six UML relationship types used across this course's diagrams
- Explain why composition is often favored over inheritance
- Pick the correct access modifier for a field or method based on who legitimately needs to see it
◆ Story
A checkout screen needs to accept a credit card today, a debit card and a UPI ID by next quarter, and a wallet balance sometime after that. Nobody hands you all four requirements on day one — they arrive one at a time, and the code has to absorb each new one without turning into a mess.
Every design pattern and every SOLID principle in this course is, underneath its specific vocabulary, a particular arrangement of four plain object-oriented ideas: encapsulation (hiding a class's internal state behind a controlled interface), abstraction (programming against what something does, not how it does it), inheritance (a subclass reusing a parent's fields and methods), and polymorphism (the same method call behaving differently depending on which actual object it's called on, decided at runtime). If any one of these four is shaky, everything built on top of it will still compile and run, but it won't make sense — you'll be able to copy the shape of a pattern without understanding why it's shaped that way.
Rather than defining these four terms up front and hoping they stick, this topic builds one small, realistic system — a payment module for a checkout flow — one requirement at a time, letting each idea show up exactly where a real problem forces it to. That's the same approach every later topic in this course takes: a concrete problem first, a naive attempt that breaks in a specific way, and then a fix that happens to have a name.
By the end, you'll have built a small hierarchy of card types, a completely separate interface for "things that can pay," and a service that can accept a brand-new payment method without a single line of its own code changing. Along the way, you'll pick up the six-symbol notation used to diagram every class relationship in this course, so that every later diagram is legible the first time you see it.
Start with the smallest possible thing: one class representing one credit card, holding exactly the data a credit card needs.
A card has a number and an owner's name. Neither of those fields should be directly editable from outside the class — nothing external should be able to reach in and silently overwrite a card number. So the fields are marked private, and a getter is the only sanctioned way to read them. That's encapsulation in its plainest form, and there's nothing wrong with this class yet.
The trouble starts the moment a second, genuinely similar payment type shows up. A debit card needs a card number and an owner name too — the exact same two fields, the exact same getters, the exact same constructor shape. Writing DebitCard by copying CreditCard and renaming the class works, and the code compiles and runs correctly.
▲ Common mistake
Copy-pasting a class because "it's basically the same, just rename it" feels harmless in the moment. The cost shows up later, not now — when a third field like an expiry date needs to be added, and it has to be added correctly, in the same shape, in every copy, forever.
Look closely at what's actually different between CreditCard and DebitCard: not the data — that's identical — but how each one pays. A credit card borrows against a credit line; a debit card deducts straight from a bank balance. Everything else is shared. That observation is the entire setup for the fix in the next section: when two classes are nearly identical and differ in one clearly separable way, that's exactly the shape inheritance exists to handle.
💻 Code example
class CreditCard { private String cardNumber; // private — nothing outside this class reads or sets this directly private String ownerName; CreditCard(String cardNumber, String ownerName) { this.cardNumber = cardNumber; this.ownerName = ownerName; } String getCardNumber() { return cardNumber; } // getters — the only door in String getOwnerName() { return ownerName; } } class DebitCard { private String cardNumber; // identical to CreditCard's field private String ownerName; // identical to CreditCard's field DebitCard(String cardNumber, String ownerName) { // identical constructor this.cardNumber = cardNumber; this.ownerName = ownerName; } String getCardNumber() { return cardNumber; } // identical getter String getOwnerName() { return ownerName; } // identical getter }
Nothing about the duplicated CreditCard/DebitCard pair is functionally broken — both classes work correctly on their own. The problem is what happens as the system keeps growing around this shape:
- Every shared field has to be edited in every copy. Add an expiry date, and it needs to land in
CreditCardandDebitCardin exactly the same form. Miss one, and the two classes silently drift apart. - There's no single type to program against. Any code that wants to treat "a card" generically — store one in a list, pass one to a method — has no shared type to use, because
CreditCardandDebitCardshare no common ancestor, only a coincidental resemblance. - Bugs fixed in one copy don't get fixed in the other. If a constructor validation rule needs to be added to reject an empty card number, it has to be remembered and re-applied in both places, and nothing enforces that.
- The duplication compounds. A third card type — a prepaid card, say — means a third copy of the same four lines, and the maintenance burden grows linearly with every new type that's really just a variation on the same theme.
None of these problems are visible by running the program once. They show up only as the codebase changes over time, which is exactly why "it compiles and the demo works" is a weak signal that a design is actually sound. The fix isn't a clever workaround — it's naming what's actually shared, and pulling it into one place.
Inheritance exists for precisely this shape of problem: two classes that are nearly identical, with one clearly separable difference. Pull everything shared into a parent class, and let each child add only what makes it distinct.
The shared fields — cardNumber and ownerName — move into a new Card class. Notice they're declared protected, not private. If they were private, DebitCard — a genuine subclass — would have no direct access to its own inherited fields and would be forced through a getter even from inside its own class hierarchy. protected is visible to the class itself and to every subclass, while still staying hidden from unrelated code outside the hierarchy entirely.
Now add the one thing that actually differs: a pay() method. Try to write it directly on Card and there's an immediate wall — Card genuinely does not know how to pay. A credit card borrows, a debit card deducts, and there's no shared implementation to write, only a shared promise that every card can pay somehow.
This is what abstract is for: if a class can't provide a real implementation of a method, but every subclass genuinely must have one, mark it abstract — on both the method and the class itself. Java enforces the rest: an abstract class can't be instantiated directly (new Card(...) becomes a compile error), and any concrete subclass is forced, at compile time, to fill in every abstract method it inherits. That compile-time guarantee is worth more than a comment that says "subclasses should implement this" — a comment can be ignored; a missing method implementation cannot.
💻 Code example
abstract class Card { // can no longer be instantiated directly protected String cardNumber; protected String ownerName; Card(String cardNumber, String ownerName) { this.cardNumber = cardNumber; this.ownerName = ownerName; } String getCardNumber() { return cardNumber; } String getOwnerName() { return ownerName; } abstract void pay(double amount); // no body — every concrete subclass MUST provide one } class CreditCard extends Card { CreditCard(String cardNumber, String ownerName) { super(cardNumber, ownerName); } void pay(double amount) { // REQUIRED — Card left this unimplemented on purpose System.out.println("Paying " + amount + " via credit card"); } } class DebitCard extends Card { DebitCard(String cardNumber, String ownerName) { super(cardNumber, ownerName); } void pay(double amount) { System.out.println("Paying " + amount + " via debit card"); } }
A new requirement lands: support UPI, and later, a wallet. Try to fit UPI into the existing Card hierarchy and it immediately doesn't work — a UPI ID isn't a card. It has no card number, nothing in common with Card's shared fields. Forcing UPI extends Card just to reuse pay() would drag along a cardNumber field that makes no sense for it. Inheritance models "is-a," and a UPI ID is not a kind of card — inheritance is the wrong tool here, even though it was exactly the right tool one step earlier.
What UPI, Wallet, CreditCard, and DebitCard genuinely share isn't data — it's a single capability: each one can be paid with. This is what an interface is for: a contract that says nothing about shared data, only "any class that implements this must provide these methods." Card stays an inheritance hierarchy underneath (credit and debit cards genuinely share fields), but a PaymentMethod interface sits above all four classes as a pure capability contract.
The payment service that ties this together should be written against the interface, not against any concrete class — it never needs to know whether it's holding a CreditCard or a UPI object, only that it can call pay(). It holds a map of PaymentMethods rather than extending anything related to payment methods — that's composition: one class holding references to other objects and using their public methods, instead of inheriting their implementation.
The payoff is visible the moment Wallet gets added: PaymentService doesn't change by a single character. The line method.pay(amount) was compiled once, but which actual pay() runs is decided fresh at runtime, based on the real object behind the reference — that's runtime polymorphism, and it's the entire reason adding a new payment type never means editing already-working code.
💻 Code example
interface PaymentMethod { void pay(double amount); // the one thing every payment method must be able to do } class UPI implements PaymentMethod { // unrelated to Card — no shared parent needed private String upiId; UPI(String upiId) { this.upiId = upiId; } public void pay(double amount) { System.out.println("Paying " + amount + " via UPI " + upiId); } } class Wallet implements PaymentMethod { public void pay(double amount) { System.out.println("Paying " + amount + " via wallet"); } } class PaymentService { private final Map<String, PaymentMethod> methods = new HashMap<>(); // composition — holds, doesn't extend void addMethod(String label, PaymentMethod method) { methods.put(label, method); } void pay(String label, double amount) { PaymentMethod method = methods.get(label); method.pay(amount); // which pay() runs is decided at runtime, not compile time } } // usage — three concrete types, one uniform call site PaymentService service = new PaymentService(); service.addMethod("primary card", new CreditCard("1234", "Asha Rao")); service.addMethod("upi", new UPI("asha@upi")); service.addMethod("wallet", new Wallet()); // added later — PaymentService itself never changes service.pay("wallet", 200);
Every class diagram in this course uses the same six relationship types. Learning to read them once means every later diagram is legible without re-explanation.
| Relationship | Meaning | Notation | This topic's example |
|---|---|---|---|
| Association | Two classes reference each other; neither owns the other's lifecycle | Plain solid line | A teacher who teaches a student |
| Aggregation | A weak "has-a" — the parts outlive the whole | Solid line, hollow diamond | A department that lists professors, who can move departments |
| Composition | A strong "has-a" — the parts die with the whole | Solid line, filled diamond | PaymentService holding its map of PaymentMethods |
| Inheritance | "Is-a" — a subclass reuses a parent's implementation | Solid line, hollow triangle | CreditCard extends Card |
| Realization | "Implements a contract" — no shared implementation, only a promise | Dashed line, hollow triangle | UPI implements PaymentMethod |
| Dependency | The loosest relationship — one class uses another only briefly, often as a parameter | Dashed line, open arrow | PaymentService.pay() using PaymentMethod only for the call |
A quick way to tell aggregation and composition apart: ask "if the container object were deleted right now, would the contained object still make sense on its own?" A PaymentService deleted along with its PaymentMethods (composition) is different from a Department losing a Professor who simply moves elsewhere (aggregation).
On access modifiers — the reason Card's fields are protected and not private earlier in this topic wasn't arbitrary:
| Modifier | Visible from |
|---|---|
public | Anywhere |
protected | Same package, plus subclasses in other packages |
| (no modifier) | Same package only — "package-private" |
private | Same class only |
This same notation and the same "favor composition over inheritance" instinct come up repeatedly across this course: any topic that replaces an inheritance hierarchy with a class that holds a collaborator instead of extending it is applying exactly the composition relationship introduced here. It's also the same reasoning behind why Java's own standard library favors small, composable interfaces — Comparable, Iterable, AutoCloseable — over large inheritance trees, letting unrelated classes share one capability without sharing an ancestor.
Q: Why did UPI need an interface instead of extending Card, when both approaches would give it a pay() method?
A: Inheritance models "is-a," and a UPI ID is not a kind of card — forcing the relationship would drag along fields like cardNumber that make no sense for it. An interface models a pure capability with no shared data, which is what UPI, Wallet, and every Card actually have in common.
Q: What's the practical difference between an abstract class and an interface, based on the Card example?
A: Card (an abstract class) shares real state and partially implemented behavior — cardNumber, getCardNumber() — across genuinely related subclasses. PaymentMethod (an interface) shares no state at all, only a method signature every implementer must fulfill, regardless of whether the implementers are related to each other.
Q: Why is PaymentService written to depend on the PaymentMethod interface instead of a concrete class like CreditCard?
A: So that adding a new payment type requires zero changes to PaymentService — it only ever calls pay() through the interface, so any class satisfying that interface works immediately, with no new branch or edit required.
Q: What's the actual difference between aggregation and composition?
A: Both are "has-a" relationships, but composition means the contained object's lifecycle is owned by the container; aggregation means the contained object can exist and continue to make sense independently of the container.
Q: Why were Card's fields declared protected instead of private?
A: private would hide cardNumber even from Card's own subclasses, forcing CreditCard and DebitCard through a getter to access their own inherited state. protected keeps it visible to the class and its subclasses while still hiding it from unrelated code outside the hierarchy.
Want a visual for this concept?
Generate a diagram tailored to “OOP Recap & UML” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →