OOP Fundamentals
Encapsulation, abstraction, inheritance, and polymorphism — the four pillars every LLD answer is built on.
Introduction
Object-oriented programming is the grammar of LLD interviews. Before patterns, UML, or system-specific classes, interviewers want to see whether you can place data and behavior together, hide unsafe state, expose small abstractions, and extend behavior without editing every caller.
For Amazon, Microsoft, Google, and Adobe style LLD rounds, the four pillars are not theory trivia. They are how you turn requirements into clean Java classes: encapsulation protects invariants, abstraction defines stable contracts, inheritance models true is-a relationships, and polymorphism lets callers depend on behavior rather than concrete types.
Learning Objectives
Explain encapsulation, abstraction, inheritance, and polymorphism with Java examples.
Choose inheritance or composition based on is-a versus has-a relationships.
Use access modifiers to protect invariants without overexposing implementation details.
Compare abstract classes and interfaces in interview-grade class design.
Distinguish compile-time polymorphism through overloading from runtime polymorphism through overriding.
Map OOP pillars directly to clean LLD entities, services, policies, and extension seams.
Core Theory
OOP is the foundation of every LLD answer
In LLD, you are not just listing classes. You are assigning responsibilities. A good class owns the state it can protect, exposes behavior that matches the domain, and depends on contracts where behavior may vary.
A useful interview loop is:
- Find domain nouns and verbs.
- Group state with the behavior that changes or validates it.
- Hide fields unless callers truly need them.
- Create interfaces or abstract bases only when you have more than one behavior or a clear future variation.
- Prefer composition for ownership and collaboration; use inheritance only for true subtype identity.
Encapsulation means protecting invariants
Encapsulation is bundling data with the methods that keep it valid. The point is not merely making fields private; the point is preventing callers from creating impossible states.
In an LLD answer, a ParkingSpot should decide whether it can accept a vehicle. A BankAccount should decide whether a withdrawal is valid. A Cart should decide how quantities change. If a controller directly mutates fields, your invariants are scattered and hard to defend.
public final class BankAccount {
private final String accountId;
private long balanceInCents;
public BankAccount(String accountId) {
if (accountId == null || accountId.isBlank()) {
throw new IllegalArgumentException("accountId is required");
}
this.accountId = accountId;
}
public long balanceInCents() {
return balanceInCents;
}
public void deposit(long amountInCents) {
if (amountInCents <= 0) {
throw new IllegalArgumentException("deposit must be positive");
}
balanceInCents += amountInCents;
}
public void withdraw(long amountInCents) {
if (amountInCents <= 0 || amountInCents > balanceInCents) {
throw new IllegalArgumentException("invalid withdrawal");
}
balanceInCents -= amountInCents;
}
}Abstraction means exposing the behavior callers need
Abstraction hides how something is done and keeps what callers depend on small. In Java, you usually express abstraction with an interface, an abstract class, or a public API on a concrete class.
The interview signal is restraint. Do not create an interface for every class by reflex. Create one when there are multiple implementations, when policy may change, or when a high-level class should not know a low-level detail.
public interface PaymentMethod {
PaymentReceipt pay(Money amount);
}
public final class CreditCardPayment implements PaymentMethod {
@Override
public PaymentReceipt pay(Money amount) {
return new PaymentReceipt("card", amount);
}
}
public final class CheckoutService {
public PaymentReceipt checkout(Cart cart, PaymentMethod paymentMethod) {
return paymentMethod.pay(cart.total());
}
}Inheritance models a true is-a relationship
Inheritance lets a subtype reuse and specialize behavior from a parent. Use it when every child is safely substitutable anywhere the parent is expected. This is the is-a test.
A Car is-a Vehicle. A Bus is-a Vehicle. A ParkingLot is not a VehicleManager child; it has levels, spots, and policies. If the relationship is role, ownership, or configuration, composition is usually cleaner.
public abstract class Vehicle {
private final String plate;
protected Vehicle(String plate) {
this.plate = plate;
}
public String plate() {
return plate;
}
public abstract int requiredSpots();
}
public final class Car extends Vehicle {
public Car(String plate) {
super(plate);
}
@Override
public int requiredSpots() {
return 1;
}
}Polymorphism lets one caller work with many implementations
Polymorphism means the same call can behave differently depending on the type involved.
Compile-time polymorphism is method overloading: the compiler chooses a method by parameter list. It is convenient for APIs but it does not replace a design seam.
Runtime polymorphism is method overriding: the object decides which implementation runs. This is what enables Strategy, State, Template Method, and most clean LLD extension points.
public final class NotificationService {
public void send(String message) {
send(message, Priority.NORMAL);
}
public void send(String message, Priority priority) {
System.out.println(priority + ": " + message);
}
}
public interface PricingPolicy {
long priceInCents(Ticket ticket);
}
public final class HourlyPricingPolicy implements PricingPolicy {
@Override
public long priceInCents(Ticket ticket) {
return ticket.hoursRoundedUp() * 200;
}
}Access modifiers are design tools
Access modifiers define your class boundary. In interviews, explain them as a way to protect invariants and reduce coupling, not as syntax trivia.
- private for fields and helper methods that should never be called from outside.
- package-private for collaborators inside the same package when no public API is needed.
- protected sparingly for inheritance hooks; it widens the surface area for subclasses.
- public only for stable behavior you are willing to support.
A strong Java LLD answer usually has private fields, public behavior methods, package-private helper classes where useful, and very few protected members.
public class Order {
private final List<OrderLine> lines = new ArrayList<>();
public void addItem(Product product, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
lines.add(new OrderLine(product, quantity));
}
public List<OrderLine> lines() {
return List.copyOf(lines);
}
}Abstract classes versus interfaces
Use an abstract class when related subclasses share state, constructor validation, or a partial algorithm. Use an interface when you want a capability or contract that unrelated classes can implement.
An abstract Vehicle can hold plate and size because every vehicle has that identity. A PricingPolicy interface is better because hourly pricing, surge pricing, and coupon pricing are strategies, not members of one family with shared state.
public abstract class Vehicle {
private final String plate;
protected Vehicle(String plate) {
this.plate = plate;
}
public String plate() {
return plate;
}
public abstract VehicleSize size();
}
public interface FeeCalculator {
long calculateFee(Ticket ticket);
}Composition models has-a relationships
Composition means one object owns or uses another object. It is the default choice for LLD because it keeps behavior swappable and avoids deep class hierarchies.
A ParkingLot has Levels. A Level has ParkingSpots. A CheckoutService has or receives a PaymentMethod. These are has-a relationships. If you model them with inheritance, you force unrelated concepts into one hierarchy and make extension harder.
public final class ParkingLot {
private final List<Level> levels;
private final PricingPolicy pricingPolicy;
public ParkingLot(List<Level> levels, PricingPolicy pricingPolicy) {
this.levels = List.copyOf(levels);
this.pricingPolicy = pricingPolicy;
}
public long feeFor(Ticket ticket) {
return pricingPolicy.priceInCents(ticket);
}
}Mapping pillars to interview classes
When you present an LLD solution, tie every class decision back to an OOP pillar:
- Encapsulation: this class owns this state and enforces this invariant.
- Abstraction: this interface hides a policy or external dependency.
- Inheritance: these subclasses are true specializations of the parent.
- Polymorphism: the caller uses the contract and the concrete object supplies behavior.
This makes your design sound intentional. Instead of saying I added a class, say the Cart owns line-item mutations, the PricingPolicy abstracts discount logic, and runtime polymorphism lets new pricing rules be added without touching CheckoutService.
Diagrams
Inheritance and runtime polymorphism
Comparisons
Abstract class vs interface
| Dimension | Abstract class | Interface | Interview guidance |
|---|---|---|---|
| Primary purpose | Share common state or a partial base algorithm | Define a capability or contract | Use the narrowest abstraction that captures variation |
| State | Can hold instance fields and constructor validation | Should not hold per-object mutable state | Put identity and shared validation in an abstract class only when all children need it |
| Inheritance limit | A class can extend only one abstract class | A class can implement many interfaces | Prefer interfaces for cross-cutting capabilities such as payable, searchable, or notifiable |
| Best LLD example | Vehicle base class with plate and size | PricingPolicy or PaymentMethod contract | Abstract class for family identity; interface for swappable behavior |
Inheritance vs composition
| Question | Inheritance | Composition | Decision rule |
|---|---|---|---|
| Relationship | Is-a | Has-a or uses-a | If the sentence is not naturally is-a, do not extend |
| Change impact | Parent changes can affect all children | Collaborators can be swapped independently | Composition is safer when behavior changes frequently |
| Interview signal | Good for stable taxonomies | Good for policies, ownership, and dependencies | Default to composition unless subtype substitutability is obvious |
| Example | Car extends Vehicle | ParkingLot has PricingPolicy | Use inheritance for identity and composition for collaboration |
Java access modifiers in LLD
| Modifier | Visible from | Typical LLD use |
|---|---|---|
| private | Only the declaring class | Fields, helper methods, and invariant-preserving state |
| package-private | Classes in the same package | Internal helpers that should not become public API |
| protected | Subclasses and same package | Rare extension hooks in abstract base classes |
| public | Every caller | Stable behavior that represents the class contract |
Best Practices
- ✓
Start with responsibilities, not fields. A class should own behavior, not just data.
- ✓
Keep fields private and expose intention-revealing methods such as reserve, cancel, pay, or assign.
- ✓
Use interfaces for policies that vary, such as pricing, matching, routing, notification, or payment.
- ✓
Use abstract classes only when subclasses share identity, state, validation, or a fixed algorithm skeleton.
- ✓
Prefer composition for services and owned parts. Deep inheritance hierarchies are hard to explain and harder to change.
- ✓
When introducing polymorphism, show the caller depending on the parent type or interface.
- ✓
Name invariants explicitly in the interview, such as one vehicle per spot or balance cannot go negative.
- ✓
Return immutable views or copies instead of exposing mutable internal collections.
Common Mistakes
- ×
Using inheritance because two classes share fields, even when there is no true is-a relationship.
- ×
Creating an interface for every class without a second implementation or clear variation point.
- ×
Putting validation in controllers instead of inside the domain object that owns the state.
- ×
Making fields public or returning mutable lists, allowing callers to bypass invariants.
- ×
Confusing overloading with runtime polymorphism and claiming it enables open-ended extension.
- ×
Using protected fields as a shortcut, which lets subclasses corrupt parent state.
- ×
Building one god class that knows every rule instead of assigning behavior to domain objects and policies.
- ×
Choosing inheritance for pricing, payment, or allocation policies when composition with an interface would be cleaner.
Quiz
0/7 answered
1.What is the strongest reason to keep fields private in an LLD class?
2.Which relationship is the best fit for inheritance?
3.What is runtime polymorphism in Java?
4.When is an interface most useful in an LLD answer?
5.Which statement best describes abstraction?
6.Why is composition often preferred over inheritance for policies?
7.What is compile-time polymorphism in Java?
Flashcards
Cheat Sheet
Four pillars: Encapsulation protects state; abstraction exposes a small contract; inheritance models true is-a subtype identity; polymorphism lets one caller work with many implementations.
Encapsulation in LLD: Keep fields private. Put validation and state changes inside the class that owns the invariant.
Abstraction in LLD: Use interfaces for variable policies and external dependencies. Keep contracts small.
Inheritance rule: Use only when the child can safely replace the parent. Car is-a Vehicle is valid; ParkingLot has Levels is composition.
Polymorphism: Overloading is compile-time convenience. Overriding is runtime extension and powers most design patterns.
Access modifiers: Private by default, public for stable behavior, package-private for internal helpers, protected only for deliberate subclass hooks.
Interview framing: For every class, say what it owns, what invariant it protects, what contract it exposes, and what variation point it keeps open.
References
- BookEffective Java — Joshua Bloch
- BookHead First Object-Oriented Analysis and Design — Brett McLaughlin, Gary Pollice, and David West
- DocsOracle Java Tutorials: Object-Oriented Programming Concepts
- BookDesign Patterns: Elements of Reusable Object-Oriented Software — Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides