Intro: What Is OOP, Really?
During a technical interview, the interviewer asked me what OOP is.
At first I thought, "Come on, you ask me this basic stuff?" But when I actually sat down and thought about it, I realized I didn't understand the concept in as much depth as I assumed I did.
So today, for anyone out there in the same boat as me, I'm writing a post about programming paradigms.
Table of Contents
- What Is OOP, Really?
- Why I decided to write about programming paradigms
- Objects, classes, and instances — the basics
- Building a
Carclass together
- Inheritance and Polymorphism
- Introducing
ElectricCarviaextends - Subtype polymorphism and upcasting
- Introducing
- Compile Time vs. Run Time
- What the compiler checks (static type only)
- What the JVM checks (dynamic type, vtable pointer)
- vtable and Dynamic Dispatch
- How overriding replaces vtable entries
- What happens without overriding
- Contrast with static binding (overloading)
- How Are Classes Actually Used in Practice? — Spring Boot & Laravel
- JPA Entity (Spring Boot) vs. Eloquent Model (Laravel)
- Interfaces + polymorphism in real systems (payment gateways)
- Layered structure and composition over inheritance
- Let's Try It Out Ourselves! (Spring Boot Hands-On)
- Project structure,
build.gradle,application.yml Car.javaentity walkthrough- Running
./gradlew bootRunand reading the Hibernate logs
- Project structure,
- Glossary
👓 OOP
OOP stands for Object-Oriented Programming.
As the name suggests, OOP is closely tied to the concept of an "object."

Most programmers probably already know what an object is, but since a lot of beginner programmers will be reading this post, let me explain it in simple terms.
Honestly, I struggled so much with understanding classes that when I first started learning to program, I spent about two weeks doing nothing but trying to wrap my head around them.
Theoretically, a class is easy enough to grasp, but its range of use is so broad (which, I suppose, is exactly what makes it such a powerful system) that you'll end up seeing it applied in all kinds of different forms as you keep programming.
But the most important thing is understanding the fundamentals.
Once you've got that down, you won't run into much trouble when you start encountering the countless class-based code patterns that are waiting for you out there.
Car class

An object is an instance of a class, and a class serves as a template that defines the fields and methods an object will have.
So, let's build a Car class together.
public class Car {
private String brand;
private int speed;
public Car(String brand) {
this.brand = brand;
this.speed = 0;
}
public void accelerate(int amount) {
speed += amount;
}
public int getSpeed() {
return speed;
}
}
Car myCar = new Car("Hyundai");
myCar.accelerate(20);
System.out.println(myCar.getSpeed()); // 20
Here, with public class Car, we created a template (a class) called Car.
From now on, whenever we call the Car class, we'll be building things based on the code we just wrote — kind of like a blueprint you reference when constructing a building.
We just stored the Car class into myCar. This is what's called an instance.
myCar can be just one instance, or we could store the Car class into myCar2 as well, and this could scale up to dozens of instances.
As you might guess from the dot notation, we called the myCar instance using the .accelerate() method.
Inside a class, you can simply declare things, but you can also include functions.
🫧 Inheritance and Polymorphism
public class ElectricCar extends Car {
@Override
public void accelerate(int amount) {
speed += amount * 2; // Electric cars accelerate faster
}
}
Car car = new ElectricCar("Tesla"); // Static type: Car, Dynamic type: ElectricCar
car.accelerate(10);
System.out.println(car.getSpeed()); // 20 (the overridden method is executed)
And now we've discovered a new form of class.
We've agreed to call this form inheritance.
As you can see, the ElectricCar class is inheriting from the Car class through the extends call.
So what role does the accelerate method inside the inherited class play?
This is an expression of polymorphism.
The type of the car instance (its Static Type) is Car, but the method that actually gets called is determined at *runtime based on the real object type (ElectricCar).
This is exactly what's called subtype polymorphism. And this kind of technique is called *upcasting.
I imagine the mention of upcasting might have suddenly left you confused, so let me explain it in more detail.
Compile Time
When the compiler looks at car.accelerate(10), it only checks car's static type, Car.
It only verifies one thing: does the accelerate method exist in the Car class?
If it does, the compilation succeeds.
At this point in time, the compiler doesn't care about the existence of ElectricCar at all.
Run Time
When the program is actually executed, the JVM checks the dynamic type of the object that car is referencing (ElectricCar).
Since this object was created on the heap as an ElectricCar, it carries metadata about its actual class in its header (to be precise, a vtable pointer).
vtable and Dynamic Dispatch
When a method is redefined with @Override, the JVM treats it as a virtual method. Every class holds something called a vtable (virtual method table) at compile time, which maps out "which actual implementation each virtual method name in this class points to."
Car's vtable:
accelerate → Car.accelerate()
ElectricCar's vtable:
accelerate → ElectricCar.accelerate() (replaced due to overriding)
When you call car.accelerate(10):
- The JVM has already confirmed at compile time — based on the static type (
Car) — whether this method call is valid. - Then, at the actual moment of execution, it looks up the vtable of the object
caris referencing (theElectricCarinstance on the heap), - and executes the
ElectricCar.accelerate()registered in that vtable.
What If There Was No Overriding?
For comparison — if ElectricCar hadn't overridden accelerate(), its vtable would simply point to Car.accelerate() as well, and the result would be that the parent class's logic runs as-is. Polymorphism only "kicks in" when a subclass actually redefines the method.
Contrast with Static Binding
For reference, this is a different mechanism from overloading. Overloading is static binding, which is decided at compile time purely based on parameter signatures, whereas the override-based polymorphism we've been discussing here is dynamic binding, where a vtable is looked up at runtime. Distinguishing between these two is the key to accurately understanding the term "polymorphism."
🎻 How Are Classes Actually Used in Practice? — Spring Boot & Laravel
There are plenty of types of OOP out there, but since I use Spring Boot and Laravel as my backend languages, I'll explain this using those two as examples.
Spring Boot — JPA Entity
@Entity public class Car {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String brand;
private int speed;
public void accelerate(int amount) {
this.speed += amount;
}
// getter/setter omitted
} The Car class is a template that represents a single row of a database table as an object.
Once you attach the @Entity annotation, JPA (Java Persistence API) maps this class to a table — this is called ORM (Object-Relational Mapping).
An instance created via new Car() ends up corresponding to a single record in the database.
If you want to look more closely at how JPA gets invoked under the hood in Spring Boot, check out this post.
HTTP request inside a Spring MVC application
Laravel (Eloquent Model)
class Car extends Model
{
protected $fillable = ['brand', 'speed'];
public function accelerate(int $amount): void
{
$this->speed += $amount;
$this->save();
}
}Laravel's Car class inherits from *Eloquent's Model.
Since the parent class Model already implements methods like save(), find(), and where(), Car reuses that functionality through inheritance.
Interfaces + Polymorphism — Why We Actually Use Them
When explaining polymorphism theoretically, people usually reach for the Dog/Duck example, but in practice, it's mostly used to make implementations swappable — think payment systems or notification systems.
public interface PaymentGateway {
void pay(double amount);
}
@Service
public class TossPaymentGateway implements PaymentGateway {
public void pay(double amount) {
// Toss API 호출 로직
}
}
@Service
public class KakaoPayGateway implements PaymentGateway {
public void pay(double amount) {
// KakaoPay API 호출 로직
}
}@Service
public class OrderService {
private final PaymentGateway paymentGateway;
// 생성자 주입 (Constructor Injection)
public OrderService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public void checkout(double amount) {
paymentGateway.pay(amount); // 실제로 어떤 구현체가 호출될지는 런타임에 DI 컨테이너가 결정
}
}OrderService only depends on the *interface (abstraction) PaymentGateway.
Whether TossPaymentGateway or KakaoPayGateway - Korean payment system - actually gets injected is decided at runtime by Spring's DI container (ApplicationContext).
This is exactly how vtable-based dynamic dispatch gets applied in real-world practice — the code that calls paymentGateway.pay() never changes, yet which class's pay() actually runs depends on the actual (dynamic) type of the injected object.
Laravel
Laravel uses either an abstract class, or an interface combined with service container binding, instead.
interface PaymentGateway
{
public function pay(float $amount): void;
}
class TossPaymentGateway implements PaymentGateway
{
public function pay(float $amount): void
{
// Toss API 호출 로직
}
}The underlying principle is identical to Spring's.
OrderService only depends on the PaymentGateway interface, and the actual implementation gets injected at runtime by Laravel's Service Container, based on the bind() configuration.
Layered Structure — Classes as Units of Responsibility
As we explored in a previous post about Spring Boot's layered structure,
Laravel divides its layered structure in a similar way.
Speaking a bit abstractly, Spring Boot tends to divide responsibilities into somewhat finer-grained layers compared to Laravel.
Either way, both enforce the Single Responsibility Principle.
If you'd like to dig into this in more depth, please check out the layered structure part on my blog post about MSA.
MSA
@RestController
public class CarController {
private final CarService carService; // 서비스 계층에 의존 (컴포지션)
public CarController(CarService carService) {
this.carService = carService;
}
@PostMapping("/cars/{id}/accelerate")
public void accelerate(@PathVariable Long id, @RequestParam int amount) {
carService.accelerate(id, amount);
}
}class CarController extends Controller
{
public function accelerate(Request $request, Car $car)
{
$car->accelerate($request->input('amount'));
return response()->json($car);
}
}Here's the important thing to notice: CarController doesn't inherit from CarService — it holds it as a field instead (*composition).
This is a real-world example of the "*composition over inheritance" principle we discussed earlier.
Inheritance is used when reusing base functionality provided by a framework (like Model), while collaboration between layers is mostly handled through composition — holding another object as a field and calling its methods.
Let's Try It Out Ourselves!
So, setting Laravel aside, let's actually take a look at how OOP works in Spring Boot!
You can try it out yourself by coloning this repository!
Clone this Repository!!!!
1. Project Structure
The image below shows the simple structure I set up for this experiment.

As you can see, it's a very simple Spring Boot project made up of just an entity, a repository, and an execution file.
2. build.gradle
First, I generated the Spring Boot project using start.spring.io (Spring Initializr), which I always use for Spring projects.
Since this project is just for a simple test, I only added Spring Data JPA and H2 as dependencies.

3. application.yml
Now let's look at the yaml file.
For this experiment, I set show-sql to true and set org.hibernate.SQL to debug mode.
You need this configuration in place so that later you can actually observe Hibernate's behavior through the console logs.

4. Car.java (Entity)
Next is the entity file.

@Entity
This annotation declares that this class is a JPA entity mapped to a database table.
The moment this is attached, Hibernate automatically recognizes this class and links it to a table named car.
@Id
This marks this field as the table's primary key (PK). Per the JPA spec, an entity must have exactly one @Id. (Without it, Hibernate has no way of knowing which row this object should be matched to.)
@GeneratedValue(strategy = GenerationType.IDENTITY)
This annotation decides who generates the primary key value.
The IDENTITY strategy means "delegate this to the database's auto-increment feature," so when save() is called, we don't have to set the id value ourselves — the database automatically fills it in sequentially: 1, 2, 3...
(Other available strategies include SEQUENCE and AUTO.)
5. Execution file
I also made sure the execution file was set up so logs would be printed.

6. Running ./gradlew bootRun
1. We called the carRepository.save(car) method in the repository, and you can see that Hibernate automatically generated and executed an INSERT SQL statement. This single line ran without us writing a single line of SQL — made possible entirely by using Hibernate's classes.

2. You can see that findById(car.getId()) gets internally translated into a SELECT statement.

3. Right after calling accelerate(), there isn't a single Hibernate log printed.
found.accelerate(20) is just an ordinary method call that changes a field value on a plain Java object, so at this point, absolutely nothing happens on the database side.

4. The moment flush() is called, the UPDATE kicks in.
We never wrote code like car.setSpeed(20), and yet Hibernate compared the object's original snapshot against its current state (dirty checking) and generated an UPDATE statement containing only the fields that changed.

🔥 Glossary
- Upcasting — Assigning a subclass instance to a variable declared with its superclass (or parent) type, such as
Car car = new ElectricCar(...). This is what allows a variable's static type and dynamic type to differ. - Compile Time — The stage when source code is translated and checked by the compiler, before the program actually runs; only static type information is available at this point.
- Run Time — The stage when the compiled program is actually executing, at which point the real (dynamic) type of an object becomes available and dynamic dispatch can occur.
- Interface — A contract that declares a set of method signatures without providing an implementation; any class that implements it must supply its own version of those methods, which is what makes polymorphism and swappable implementations possible.
- Eloquent — Laravel's built-in ORM (Object-Relational Mapping) layer, which lets a PHP class (a
Model) represent and interact with a database table using object-oriented syntax instead of raw SQL. - Composition — A design technique where a class achieves functionality by holding an instance of another class as a field and delegating to its methods, rather than inheriting from it.
- Composition over inheritance — A design principle suggesting that combining objects (composition) is generally more flexible and less tightly coupled than extending a base class (inheritance), especially when the relationship between classes isn't a strict "is-a" relationship.
🍾 Conclusion
In the next post, we'll move beyond OOP to explore other programming paradigms, like functional and declarative programming, and see how they offer a fundamentally different way of thinking about code.


