Opening low-level design
Progress is saved as you study.
Opening low-level design
Progress is saved as you study.
Opening your learning path
Progress is saved as you study.
OOP Pillars & Class Relationships is a core lesson in the OOP Foundations & Class Design family of the Thita low level design course.
OOP pillars, SOLID principles, entity modeling and class extraction.
It is part of the Interview Soon path: the LLD material that appears in a standard interview loop rather than only in a specialist one.
LLD interviews test class design because every pattern, API, and object model depends on it. Candidates often know definitions like encapsulation and inheritance. They fail when they pick the wrong relationship between classes under time pressure.
The interviewer is checking one instinct: can you model the domain with clear boundaries, or will you create public data bags and inheritance chains that break when requirements change?
Encapsulation means hiding object data behind methods, so callers cannot directly corrupt internal state like balances, inventory counts, or booking status.
Abstraction means exposing what an object can do while hiding how it does it, such as exposing pay() without revealing whether payment uses card, wallet, or UPI.
Inheritance means sharing behavior through a true IS-A relationship, such as CreditCardPayment IS-A PaymentMethod.
Polymorphism means substituting different implementations through the same interface, so checkout can call payment.pay(amount) without knowing the concrete payment type.
Use these four ideas as design tools. Encapsulation protects correctness. Abstraction reduces caller knowledge. Inheritance models taxonomy. Polymorphism removes type-based branching.
Expect this topic in designs like:
Avoid starting with classes too early. First identify relationships between concepts. Most bad LLD designs come from using inheritance where composition or dependency fits better.
Use this decision tree whenever you connect two classes.
| Relationship | Meaning | Example | Design Choice |
|---|---|---|---|
| IS-A | One type is a specialized version of another | Car IS-A Vehicle | Inheritance |
| HAS-A | One object owns another as part of its state | Order HAS-A List<OrderItem> | Composition |
| USES | One object needs another to perform an action | CheckoutService USES PaymentGateway | Dependency |
| KNOWS-ABOUT | One object keeps a reference and does not own its lifecycle | Driver KNOWS-ABOUT Vehicle | Association |
Use inheritance only when “X is a Y” sounds correct. Car IS-A Engine is wrong, so Car should contain an Engine. Order IS-A Payment is wrong, so Order may use a PaymentMethod.
Ask in order: is it IS-A, does it own, does it call temporarily, else it just knows. Pick inheritance, composition, dependency or association.
Composition means building behavior by placing objects inside other objects. LLD interviews test this instinct often because changing requirements usually break inheritance-heavy designs first.
Default to composition because it keeps classes small and replaceable. Use inheritance only when the child can fully substitute the parent without surprising the caller. The formal name is the Liskov Substitution Principle. The interview version is simpler: if code expecting a parent breaks when given a child, the inheritance is wrong.
1class EmailSender {
2 public void sendEmail(String message) {}
3}
4
5class Invoice extends EmailSender {
6 public void generateInvoice() {}
7}An Invoice is not an EmailSender. The candidate reused sendEmail through inheritance, and the model now lies. False inheritance spreads quickly.
1class Invoice {
2 private final EmailSender emailSender;
3
4 Invoice(EmailSender emailSender) {
5 this.emailSender = emailSender;
6 }
7
8 public void sendInvoice(String message) {
9 emailSender.sendEmail(message);
10 }
11}Now Invoice HAS-A EmailSender. The relationship is honest, testable, and easy to change. If invoices later use SMS, replace the sender without rewriting an invoice hierarchy.
A common failure is placing type checks inside service methods. The code looks simple at first. Every new type forces you to edit old code.
The if-chain must be edited for every new payment type. With a PaymentMethod interface, a new class plugs in and Checkout never changes.
1int pay(String paymentType, int amount) {
2 if (paymentType.equals("CARD")) return amount;
3 if (paymentType.equals("WALLET")) return amount;
4 if (paymentType.equals("UPI")) return amount;
5 throw new IllegalArgumentException("Unsupported");
6}The caller must pass type information, and the service must know every payment variant. Adding NetBanking modifies existing code, which violates the open-closed principle.
1interface PaymentMethod {
2 int pay(int amount);
3}Each payment class owns its own behavior. The checkout flow depends on the interface. Payment variants stay behind that interface. Interviewers expect this move when they see branching by type.
Access modifiers define what other classes are allowed to know. Public fields destroy encapsulation and make invariants unenforceable.
Use this rule:
| Modifier | Use For | Recommendation |
|---|---|---|
private | Internal state and helper methods | Default choice |
protected | Chosen extension points for subclasses | Use rarely |
public | Stable API methods callers are meant to use | Keep small |
| Public fields | Direct external mutation | Avoid completely |
A BankAccount with a public balance field cannot prevent negative balances. A BankAccount with withdraw() can validate rules before changing state. Encapsulation protects correctness.
Do not expose internal mutable collections directly. If Order returns its internal List<OrderItem>, any caller can add, remove, or mutate items without order validation. Return a read-only view or a copy. Provide methods like addItem() and removeItem() that enforce rules.
Use an interface to define a capability, such as Payable, Notifiable, or Searchable.
Use an abstract class when related classes share both a contract and common state or behavior.
Use a concrete class when the object is directly instantiable.
Depend on interfaces at service boundaries. Use abstract classes only when shared state is useful. Keep concrete classes focused. Avoid abstract classes created only for possible future change. Premature abstraction creates names without responsibilities.
The diamond problem happens when a class inherits from two parents that both inherit from the same base. The ambiguity is which parent behavior should win.
If both Scanner and Printer implement start(), MultiFunctionMachine.start() becomes ambiguous. Some languages restrict multiple class inheritance to avoid that ambiguity. Java allows multiple interfaces and one class inheritance path.
Use interfaces plus composition. MultiFunctionMachine can implement Scannable and Printable, then delegate to a ScannerModule and PrinterModule. That design avoids ambiguous inherited state and keeps each behavior replaceable.
| Concept | Use When | Avoid When |
|---|---|---|
| Inheritance | Child is a true substitute for parent | You only want to reuse code |
| Composition | Object owns another object as part of behavior | The contained object has no real lifecycle or role |
| Dependency | Object temporarily calls another service | You need long-term ownership |
| Association | Object references another domain object | Ownership is required |
Default to composition because most real systems are built from collaborating objects. Use inheritance for stable domain hierarchies like Vehicle -> Car or PaymentMethod -> CardPayment. Avoid inheritance for roles, features, or optional behavior because those change often.
1interface Payable {
2 int pay(int amount);
3}
4
5abstract class Account implements Payable {
6 private final String id;
7 protected int balance;
8
9 protected Account(String id, int balance) {
10 this.id = id;
11 this.balance = balance;
12 }
13
14 public String id() { return id; }
15 public int balance() { return balance; }
16
17 protected void debit(int amount) {
18 if (amount > balance) throw new IllegalArgumentException("Insufficient");
19 balance -= amount;
20 }
21
22 public abstract String type();
23}
24
25class Wallet extends Account {
26 public Wallet(String id, int balance) { super(id, balance); }
27
28 @Override public int pay(int amount) {
29 debit(amount);
30 return amount;
31 }
32
33 @Override public String type() { return "Wallet"; }
34}
35
36public class Main {
37 public static void main(String[] args) {
38 Payable payment = new Wallet("w1", 500);
39 System.out.println(payment.pay(120));
40 }
41}Payable is the abstraction. Account shares protected behavior. Wallet provides the concrete implementation. Balance stays hidden, so callers cannot mutate it without validation.
1from abc import ABC, abstractmethod
2from dataclasses import dataclass
3
4class Payable(ABC):
5 @abstractmethod
6 def pay(self, amount: int) -> int:
7 pass
8
9@dataclass
10class Account(Payable):
11 _id: str
12 _balance: int
13
14 @property
15 def balance(self) -> int:
16 return self._balance
17
18 def _debit(self, amount: int) -> None:
19 if amount > self._balance:
20 raise ValueError("Insufficient")
21 self._balance -= amount
22
23 @abstractmethod
24 def type(self) -> str:
25 pass
26
27@dataclass
28class Wallet(Account):
29 def pay(self, amount: int) -> int:
30 self._debit(amount)
31 return amount
32
33 def type(self) -> str:
34 return "Wallet"
35
36payment: Payable = Wallet("w1", 500)
37print(payment.pay(120))Python has weaker privacy enforcement than Java or C++. The underscore marks internal state, ABC defines the contract, and callers interact through Payable. In interviews, this counts as Pythonic encapsulation when paired with clear methods.
C++ needs a virtual destructor for interfaces because objects are often deleted through base-class pointers. unique_ptr expresses ownership clearly and avoids manual memory management. Wallet final blocks further inheritance.
A checkout system should not know whether payment comes from wallet, card, or UPI. It should depend on a PaymentMethod or Payable abstraction and call pay(amount). Each payment type owns its validation, limits, and external gateway behavior.
Adding GiftCardPayment creates a new class without editing checkout logic. Polymorphism reduces edits to stable code.
Deep inheritance usually means the design encodes too many variations into class ancestry.
Each level inherits assumptions from every parent. Rental behavior should live in a rental policy object. Battery behavior should live in a battery object. Autopilot behavior should live in an autopilot object.
Stay under three inheritance levels in interviews. If you feel pressure to go deeper, extract composed responsibilities. Most LLD designs get cleaner when capabilities become objects instead of parent classes.
Default: Start with private fields, public behavior methods, and composition between domain objects. Use interfaces for service boundaries and polymorphic behavior. Use abstract classes only when subclasses share real state or protected helper behavior. Use inheritance only for true IS-A relationships where the child can safely replace the parent.
Interviewers expect this answer because it handles changing requirements. It also prevents the two most common failures: public mutable state and false inheritance. When unsure, pick composition first. Use inheritance only when substitutability is clear.
Warning: The most common failures are predictable:
- Making every field public, which removes control over object validity.
- Creating deep inheritance hierarchies with more than three levels.
- Inheriting only to reuse code.
- Exposing mutable internal collections like
List<OrderItem>directly.- Writing large
if/elseorswitchchains based on object type.- Using abstract classes before responsibilities are clear.
- Confusing HAS-A with IS-A, such as making
ParkingLotinherit fromParkingSpot.- Forgetting virtual destructors in C++ base interfaces.
- Treating Python underscores as optional and letting callers mutate internal fields freely.
- Designing classes as data containers first.
No related case studies are attached to this subpattern. These concepts apply to later LLD cases because class boundaries and relationships come first.