C
Java/OOP/Lesson 04

The 5 Pillars of OOP — Classes, Encapsulation, Inheritance, Polymorphism, and Interfaces

60 min·theory

The 5 Pillars of OOP — Classes, Encapsulation, Inheritance, Polymorphism, and Interfaces

🎯 What you'll be able to do after this lesson

After completing this lesson, you'll be able to confidently handle the following three things.

  • ✅ Define encapsulation, inheritance, polymorphism, and abstraction — with code examples
  • ✅ Answer interview questions on all 5 SOLID principles (especially SRP, OCP, and DIP)
  • ✅ Explain in one sentence why composition beats inheritance

Treat your learning goals as a checklist — close this lesson only once you can answer all of them.

What is object-oriented programming — writing code *like the real world works*

The Core Idea

Object-oriented programming (OOP) = a programming paradigm that models real-world things as objects and has those objects exchange messages with one another to get things done. It is the central philosophy of Java.

Why OOP?

In the old C world, functions and data lived separately. A person's name and age were variables; walking and talking were functions — kept apart, so as a program grew larger it became impossible to tell what was what.

OOP bundles data and behavior together. Inside a "Person" class you have fields like name and age alongside methods like walk and talk — just as a real person has a body (data) and actions at the same time.

Class vs. Object — the mold and the fish-shaped cake

These two terms cause the most confusion. Here's an analogy.

  • Class = the mold. A blueprint. The plan that says "I'll make something shaped like this."
  • Object = the actual cake that comes out. You can make 1, 2, or 100 of them, and each one is independent.
java
class Person {           // Class = blueprint
    String name;
    int age;
}

Person a = new Person();   // Object 1 = first fish-shaped cake
Person b = new Person();   // Object 2 = second (completely separate from a)

The new keyword is the act of baking a cake. It carves out a new space in memory and records that location in the variable a.

Encapsulation — keeping outsiders from touching what they shouldn't

What if anyone could change your bank balance at will? That would be disastrous. So in OOP, direct external access is blocked, and data can only be handled through designated methods.

java
class Account {
    private int balance;       // private → no direct external access

    public void deposit(int amount) {
        if (amount <= 0) throw new IllegalArgumentException("Must be greater than 0");
        balance += amount;
    }

public int checkBalance() { return balance; }

}

The private keyword blocks direct access, and only official channels like deposit() are left open. Inside those channels you can handle all the extra work — validation, logging, synchronization. That's the essence of encapsulation.

Inheritance — reusing common parts

Suppose you're building class hierarchies for various animals. Dogs, cats, birds — they all move and eat. Writing that separately every time is duplication.

java
class Animal {
    void eats() { ... }
    void moves() { ... }
}

class Dog extends Animal {
    void barks() { ... }   // Only additional behavior
}

A dog automatically *inherits everything from Animal* and only needs to add its *own unique behavior*.
**But inheritance is a dangerous weapon.** Stack it too deep and *one change in a parent ripples through all its children* — making changes very hard. That's why modern Java favors *composition* over inheritance. Instead of a dog *extending* Animal, a dog *holds* `Movable` and `Eatable` interfaces.

## Polymorphism — the same interface, different behavior

Polymorphism means the same method call produces *different results depending on the object*.

java

Animal a = new Dog();

Animal b = new Cat();

a.sound(); // "Woof woof"

b.sound(); // "Meow"

``

Both a and b are of type Animal, but the sound() method behaves differently depending on the actual runtime type. It's decided at runtime.

Thanks to this, you can mix dogs and cats in a single List and process them all with one loop, each doing its own thing.

Interface vs. Abstract Class — a commonly confused pair

Both are abstract types that cannot be instantiated directly. The difference:

  • Interface — a role. A capability spec like "can be compared (Comparable)" or "can be iterated (Iterable)". Multiple implementation is possible (implements A, B, C).
  • Abstract class — a common skeleton. "These methods are already implemented, but some must be filled in by subclasses." Single inheritance only (extends A).

Rule of thumb: use an interface when you want to define only a role, and an abstract class when you need shared code + some abstract methods. Since Java 8 interfaces can have default method implementations, the line has blurred — but the role vs. skeleton intuition still holds.

Quick Recap

OOP is about breaking a complex program into small units (classes) to manage it. The 5 core principles:

PrincipleOne-liner
Class / ObjectBlueprint / instance. Create objects with new
Encapsulationprivate + getters/setters to block direct access
Inheritanceextends to reuse common parts (avoid overuse)
PolymorphismSame method, different behavior. Enables List`
InterfaceDefines a role. Multiple implementation allowed

> 💡 In the real world: Java back-end development is 95% OOP. Functional and reactive styles are supporting tools. OOP is your primary weapon.

Exception Handling — dealing with errors *gracefully*

What is an exception?

When a program hits an unexpected situation — a file doesn't exist, a DB connection drops, a division by zero — Java throws an Exception object. If nobody catches it, the program crashes.

Unlike old C where you'd return an error code, this mechanism propagates errors on a separate path.

Two kinds of exceptions — Checked vs. Unchecked

This is Java's quirky feature — a distinction you almost never see in other languages.

  • Checked: the compiler forces you to handle it. IOException, SQLException, etc. A try-catch or throws declaration is mandatory.
  • Unchecked (RuntimeException): handling is recommended but not enforced. NullPointerException, IllegalArgumentException, etc.

This distinction has long been controversial. Spring and modern libraries barely use Checked exceptions — mandatory handling is seen as polluting the code. Kotlin and C# have only Unchecked exceptions.

try-catch-finally

java
try {
    int x = Integer.parseInt(input);
} catch (NumberFormatException e) {
    log.error("Parsing failed: {}", input, e);
    throw new BusinessException("Invalid input format");
} finally {
    cleanup();    // Always executed *regardless* of success or failure
}

finally used to be the go-to for resource cleanup, but try-with-resources (Java 7+) is cleaner:

java
try (BufferedReader r = new BufferedReader(new FileReader(f))) {
    return r.readLine();
}   // r.close() automatically called

Any resource implementing AutoCloseable is automatically closed when the block ends. DB connections, files, and network sockets all follow this pattern as standard.

4 common pitfalls

1. catch (Exception e) { } — empty catch: the scariest anti-pattern. It silently swallows errors. The beginning of debugging hell. At the very least, leave a log.

2. e.printStackTrace(): stack traces get scattered to standard output. In production, use a logger:

java
catch (Exception e) {
    log.error("Order creation failed: userId={}", userId, e);  // Structured log
}

3. Two different things in one try block: if you bundle different kinds of work inside one try block, you won't know where the error came from. Multiple small try blocks is better.

4. Exceptions without domain meaning: throw new RuntimeException("error") versus throw new InsufficientBalanceException() — a meaningful name makes a huge difference for debugging and documentation.

Quick Recap

Exception handling isn't about plastering your code with defensive checks. The core is to clearly separate normal flow from exceptional flow, and to enable future tracing with meaningful messages and logging.

Generics — treating *types like variables*

Why do we need generics?

Before Java 5, a List could hold any type. That seemed convenient, but it led to frequent ClassCastException explosions at runtime.

java
// Old days
List names = new ArrayList();
names.add("Hong Gil-dong");
names.add(42);            // Accident! An Integer was added
String x = (String) names.get(1);   // Runtime ClassCastException

Generics is a mechanism that validates types at compile time.

java
List<String> names = new ArrayList<>();
names.add("Hong Gil-dong");
names.add(42);            // Compile error! ✅

Runtime explosions → caught in advance by the compiler. Type safety is the core value.

Basic Usage

java
public <T> T findById(Class<T> type, Long id) {
    // Same logic no matter what T is
    return em.find(type, id);
}

User u = findById(User.class, 42L);
Order o = findById(Order.class, 100L);

<T> is a type parameter — similar in concept to a method parameter. The compiler infers the type at the call site.

Bounded Types — conditions on T

java
public <T extends Number> double sum(List<T> list) {
    double total = 0;
    for (T n : list) total += n.doubleValue();
    return total;
}

<T extends Number> means T can only be Number or one of its descendants. Only numeric types like Integer, Long, and Double are allowed through.

Wildcards — what ? means

This is the most confusing part.

  • List<? extends Number>some subtype of Number (unknown exactly). Read-only; no writing.
  • List<? super Integer>some supertype of Integer (unknown exactly). Writing is possible (Integer only); reading yields only Object.

The PECS principle — Producer Extends, Consumer Super:

  • Where data is read out (producer) → extends
  • Where data is only put in (consumer) → super

> No need to memorize. You only need to go deep on this when building a generic library. As a user, List<User> and similar straightforward usages cover 99% of cases.

Type Erasure — gone at runtime

A curious property of Java generics. After compilation, all <T> is erased and replaced with Object. As a result:

  • new T() is not allowed (T is unknown at runtime) → you must receive a Class<T> argument instead
  • Creating a T[] array is not allowed
  • instanceof List<String> is not allowed → only instanceof List<?> works

This is a design debt Java made once and can't undo. It would have been better as C#'s Reified Generics, but it can't be changed for compatibility reasons.

Quick Recap

Generics are a tool for compile-time type safety. Basic usage like List<User> is a must. Wildcards and bounds are something you go deep on when writing libraries.

enum + Annotations — Java's *meta tools*

enum — not just constants

In other languages, an enum is a simple collection of constants. Java's enum is far more powerful — each value is an object and can have methods, fields, and interface implementations.

java
public enum PaymentStatus {
    PENDING("Payment Pending", 0),
    PAID("Payment Complete", 1),
    REFUNDED("Refund Complete", 2);

    private final String name;
    private final int code;

    PaymentStatus(String name, int code) {
        this.name = name;
        this.code = code;
    }
    public String name() { return name; }
}

Each enum value can carry extra data and you can define methods on it too. It's frequently used for state machines and the Strategy pattern.

java
public enum Discount {
    NONE { public int apply(int price) { return price; } },
    TEN  { public int apply(int price) { return (int)(price * 0.9); } },
    VIP  { public int apply(int price) { return (int)(price * 0.7); } };
    public abstract int apply(int price);
}

int finalPrice = Discount.VIP.apply(10000);   // 7000

Instead of a cascade of if-else, each enum value owns its own behavior.

Annotations — attaching metadata to code

@Override, @Deprecated, @SuppressWarnings — everything starting with @ is an annotation. It's not the code itself but information about the code.

java
@Override         // Verify parent method override (compiler validation)
public String toString() { return ...; }

@Deprecated       // Discourage use (IDE warning)
public void oldApi() { ... }

Annotations are the heart of Spring

If you're doing Java back-end development, annotations will make up 80% of your code. Nearly all of Spring's magic is annotation-based:

  • @Component, @Service, @Repository — bean registration
  • @Autowired — dependency injection
  • @RestController + @GetMapping("/users") — web routing
  • @Transactional — automatic transaction management
  • @Entity + @Id — JPA mapping
java
@RestController
@RequestMapping("/api/users")
public class UserController {
    @Autowired
    private UserService service;

    @GetMapping("/{id}")
    public User get(@PathVariable Long id) {
        return service.findById(id);
    }
}

Annotations do nothing by themselves. A framework like Spring reads annotations at runtime and automatically performs the corresponding behavior (routing, DI, transactions).

Quick Recap

  • enum = type-safe constants + each value can have object-like methods and fields
  • Annotation = metadata. Not executed directly. Read and acted upon by frameworks and tools

These two are what make Java's declarative style possible. Instead of commanding "do it this way", you just declare "this is what it is" and the tools handle the rest.

💻 📌 OOP in Practice — no need to memorize, just know the patterns
// ============================================
// 1. Builder Pattern — Creating Validatable Objects
// ============================================
public class User {
    private final String email;
    private final String name;
    private int points;

    private User(Builder b) {
        if (!b.email.contains("@"))
            throw new IllegalArgumentException("Email format X");
        this.email = b.email;
        this.name = b.name;
        this.points = b.points;
    }
    public static Builder builder() { return new Builder(); }

    public static class Builder {
        private String email, name;
        private int point = 0;

        public Builder email(String x) { this.email = x; return this; }
        public Builder name(String x) { this.name = x; return this; }
        public Builder point(int x) { this.point = x; return this; }
        public User build() { return new User(this); }
    }
}

// Usage
User u = User.builder().email("[email protected]").name("Hong Gil-dong").build();
// ============================================
// 2. Strategy Pattern — Naturally with enum
// ============================================
public enum DeliveryMethod {
    NORMAL { public int fee(int weight) { return 3000; } },
    SAME_DAY { public int fee(int weight) { return 10000 + weight * 100; } },
    FREE { public int fee(int weight) { return 0; } };
    public abstract int fee(int weight);
}

int cost = DeliveryMethod.SAME_DAY.fee(2);    // 10200

// ============================================
// 3. Generic Repository — Reusable Base
// ============================================
public abstract class Repository<T, ID> {
    public abstract T findById(ID id);
    public abstract List<T> findAll();
    public abstract T save(T entity);
}

public class UserRepository extends Repository<User, Long> {
    @Override public User findById(Long id) { /* ... */ }
    // ...
}

// ============================================
// 4. Exception Handling — Meaningfully
// ============================================
public class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(String msg) { super(msg); }
}

public void withdraw(Long accountId, int amount) {
    var account = accountRepo.findById(accountId)
        .orElseThrow(() -> new EntityNotFoundException("Account not found"));
    if (account.balance() < amount) {
        throw new InsufficientBalanceException("Insufficient: current " + account.balance());
    }
    account.withdraw(amount);
}

The 5 SOLID Principles — bad vs. good code, side by side

What is SOLID?

An acronym for 5 object-oriented design principles, compiled by Robert C. Martin (Uncle Bob). You will be asked about these in every interview.

S — Single Responsibility

One class does one thing. It should have only one reason to change.

java
// ❌ Bad — User info + email sending + DB saving in one class
class User {
    void save() { /* DB */ }
    void sendWelcomeEmail() { /* SMTP */ }
}

// ✅ Good — Responsibility separation
class User { /* Data only */ }
class UserRepository { void save(User u) { } }
class EmailService { void sendWelcome(User u) { } }

O — Open/Closed

Open for extension, closed for modification. Adding new features should be possible without changing existing code.

java
// ❌ Add if-else for each new payment method
if (type.equals("CARD")) { ... } else if (type.equals("KAKAO")) { ... }

// ✅ Interface + implementation — New method as a new class
interface PaymentMethod { void pay(int amount); }
class CardPayment implements PaymentMethod { ... }
class KakaoPayment implements PaymentMethod { ... }

L — Liskov Substitution

A subclass must be fully substitutable for its parent. Code that uses the parent must still work when given a child.

java
// ❌ Broken if a square inherits from a rectangle
class Rectangle { void setWidth(int w); void setHeight(int h); }
class Square extends Rectangle {
    void setWidth(int w) { super.setWidth(w); super.setHeight(w); }  // Parent behavior changed
}

Separating via interfaces instead of inheritance is often the right answer.

I — Interface Segregation

Break large interfaces into smaller ones. Don't force implementors to define methods they don't use.

java
// ❌ Huge interface
interface Worker { void work(); void eat(); void sleep(); }

// ✅ Separate by role
interface Workable { void work(); }
interface Eatable { void eat(); }

D — Dependency Inversion

Depend on interfaces, not concrete classes. Spring's DI is the practical application of this principle.

java
// ❌ Depend on concrete class
class UserService { MySQLRepository repo = new MySQLRepository(); }

// ✅ Depend on interface — Freedom to test/swap DB
class UserService {
    private final UserRepository repo;
    UserService(UserRepository repo) { this.repo = repo; }
}

No need to memorize — the principles are outcomes

When you write good code consistently, these principles follow naturally. Don't try to memorize the names — just remember the intent behind each one.

☕ Try it yourself — class, inheritance, and polymorphism

Encapsulation, inheritance, and polymorphism all at once. An animal hierarchy example.
☕ Java
✏️ 코드 편집기
📟 출력 결과
▶ Press the Run button
💡 코드를 직접 수정하고 실행해보세요. 변수값을 바꾸거나 println을 추가해 결과를 확인하세요!
☁️ Judge0 API로 서버에서 실행 — Java / Python / JS / C++ 지원

🤖 Try asking AI this way

Knowing the concepts in this lesson lets you give AI specific, precise instructions. Not a vague "fix this" but a request backed by vocabulary — and that's where token savings start.

  • "Check this class for SOLID violations (especially SRP) and suggest how to split it."
  • "Apply the Builder pattern to this object-creation code."
  • "Refactor this inheritance structure using interface segregation."

Why does this save tokens?

Without the vocabulary, even after getting an AI response you end up asking "what does that mean?" again. That follow-up question is what eats the tokens. Learn the concepts once and the conversation ends in one go.

The 5 Pillars of OOP — Classes, Encapsulation, Inheritance, Polymorphism, and Interfaces - Java