3.1. The OOP Core: Designing Better Classes with Inheritance.
If you’ve been following this series, in the last blog we built our very first class for a simple racing game. We created a Car class with…
3.1. The OOP Core: Designing Better Classes with Inheritance.
If you’ve been following this series, in the last blog we built our very first class for a simple racing game. We created a Car class with fields like brand, model, and speed, along with methods such as start(), accelerate(), and displayStatus(). Then we created multiple car objects from that blueprint and watched each one maintain its own independent state.
Everything felt neat, organised, and honestly… pretty satisfying.
Let’s continue building that same racing game. Until now, our game only had cars. But one day, the game designer comes to us and says,
“Can we also let players race on bikes?” Sounds like a reasonable request. So we create a Bike class.
Just like our Car class, it needs a brand, a model, and a speed. It also needs methods like start(), accelerate(), and displayStatus() because bikes also participate in races.
A few days later, another request arrives.
“Let’s make the game more interesting by adding trucks as well.” Alright. Now we create a Truck class. And once again, we’re writing the same fields…brand, model, speed. And the same methods… start(), accelerate(), displayStatus().
At this point, something starts feeling… odd.
Not because the code is wrong. The game still works perfectly. But because we’re writing the same things over and over again.
Now imagine it’s a few months later. The game introduces a new feature.
Every vehicle in the game should now have a unique registrationNumber.
Suddenly, you have to update the Car class. Then the Bike class. Then the Truck class. And every other vehicle class you’ve added to the game.
The exact same change…In multiple places.
A few weeks later, another update comes in. Every vehicle should also have an engineNumber. Once again…The same update. Everywhere.
After doing this a few times, you naturally begin asking yourself,
“Why am I copying the same code into every vehicle?”
“These are all vehicles participating in the same racing game.”
“They already share so much in common.”
And that’s exactly the thought Object-Oriented Programming wants us to have.
Not every class is completely unique. Sometimes multiple classes are simply specialised versions of the same thing.
A Car is a Vehicle. A Bike is a Vehicle. A Truck is a Vehicle.
Each one has its own identity and may have its own unique behaviour, but they all share a common set of properties and behaviours because, at the end of the day, they’re all vehicles.
So instead of rewriting those common things again and again, wouldn’t it be nice if we could define them just once and let every specialised class automatically get them?
That’s exactly the problem we’ll solve in this blog. And the OOP concept that helps us do that is called Inheritance.
So… What Exactly Is Inheritance?

If we think about the problem we just faced, the issue wasn’t that our classes were wrong.
The problem was that they all shared a lot of common information, yet we were writing that common code again and again.
This is exactly where Inheritance comes in.
Inheritance allows one class to reuse the properties and behaviours of another class instead of writing everything from scratch.
Instead of creating everything again, it simply says, “I’ll take everything that already exists there, and then I’ll add only what’s unique about me.”
Let’s go back to our racing game.
Every vehicle has: a brand, a model, a speed. Every vehicle can: start(), accelerate(), and displayStatus().
So rather than placing these members inside every individual class, we can create one common class called Vehicle.
Now the specialised classes simply build on top of it.
Vehicle
│
├── Car
├── Bike
└── Truck
Suddenly, our code becomes much cleaner.
- The
Vehicleclass contains everything common to all vehicles. - The
Carclass only contains things that are unique to cars. - The
Bikeclass only contains things that are unique to bikes. - The
Truckclass only contains things that are unique to trucks.
Instead of repeating ourselves, we’re simply reusing what already exists. And that’s the biggest purpose of inheritance.
Not to create deep class hierarchies. Not to make code look fancy. But to avoid duplication while modelling real-world relationships naturally.
Parent Class and Child Class
Now that we have a common Vehicle class, let's learn the two most common terms you'll hear whenever people talk about inheritance.
The class whose properties and behaviours are reused is called the Parent Class (also known as the Base Class or Superclass).
Vehicle
The class that inherits from it is called the Child Class (also known as the Derived Class or Subclass).
Vehicle
△
│
Car
Here,
Vehicleis the Parent Class.Caris the Child Class.
Since Car inherits from Vehicle, it automatically gets access to all the reusable members defined inside Vehicle.
Instead of writing those members again, the child simply reuses them and focuses only on what’s unique about itself.
The Most Important Rule of Inheritance
Whenever you’re wondering whether inheritance is the right choice, ask yourself one simple question:
Can I honestly say “A Child IS A Parent”?
For our example,
- A Car is a Vehicle. ✅
- A Bike is a Vehicle. ✅
- A Truck is a Vehicle. ✅
These relationships make complete sense. That’s why inheritance fits naturally here.
This is called the IS-A Relationship, and it’s one of the easiest ways to recognise when inheritance is appropriate.
Building Our First Inheritance Hierarchy
Let’s go back to our racing game one last time. Visually, our design now looks like this:
Vehicle
△
┌────────┼────────┐
│ │ │
Car Bike Truck
Notice what’s happening here.
Vehicle sits at the top because it contains everything that is common to all vehicles.
Car, Bike, and Truck don't need to redefine those common members anymore. They simply build upon Vehicle and only add what's unique about themselves.
This relationship is exactly what we call Inheritance.
In Java, we express this relationship using the extends keyword.
class Vehicle {
}
class Car extends Vehicle {
}
class Bike extends Vehicle {
}
class Truck extends Vehicle {
}
The moment Java sees the extends keyword, It understands that Car, Bike and Truck is a Vehicle. This is why extends is often read as:
Car extends Vehicle → Car inherits from Vehicle.
Or even more naturally,
Car is a specialised version of Vehicle.
Now let’s make our Vehicle class contain everything that is common.
class Vehicle {
protected String brand;
protected String model;
protected int speed;
public void start() {
System.out.println("Vehicle Started");
}
public void accelerate() {
speed += 10;
System.out.println(brand + " - " + model + " is accelerating at" +speed+ " km/h");
}
public void displayStatus() {
System.out.println(brand + " " + model + " : " + speed + " km/h");
}
}
And now look at our Car class.
class Car extends Vehicle {
private boolean nitroActivated;
public void activateNitro() {
nitroActivated = true;
}
}
class Bike extends Vehicle {
private boolean swimMode;
public void performSwim() {
swimMode = true;
}
}
The Vehicle class already provides everything every vehicle needs, like starting, accelerating, and displaying its status.
The Car class only focuses on what's unique to a racing car.
In our game, cars have the ability to activate Nitro Boost, so that’s the only behaviour we added here.
Let’s verify inheritance ourselves
public class Main {
public static void main(String[] args) {
Car ferrari = new Car();
ferrari.brand = "Ferrari";
ferrari.model = "488 GTB";
ferrari.speed = 320;
ferrari.accelerate();
ferrari.activateNitro();
Bike ducati = new Bike();
ducati.brand = "Ducati";
ducati.model = "Panigale V4";
ducati.speed = 299;
ducati.accelerate();
ducati.performSwim();
ferrari.displayStatus();
ducati.displayStatus();
}
}
Output :
Ferrari - 488 GTB is accelerating at 330 km/h
Ducati - Panigale V4 is accelerating at 309 km/h
Brand: Ferrari, Model: 488 GTB, Speed: 320 km/h
Brand: Ducati, Model: Panigale V4, Speed: 299 km/h
you might be wondering…
- “How can a
Carobject use fields and methods that aren't even written inside theCarclass?" - When we call
ferrari.accelerate(), how does Java know where to find theaccelerate()method ? - We only wrote one displayStatus() method inside the Vehicle class. Yet, when we call it using a Car object, it prints Ferrari’s details. And when we call the very same method using a Bike object, it prints Ducati’s details. How ?
All of these questions have a single answer, and it lies in understanding the internal structure of an inherited object.
Internal Structure of an Inherited Object
Let’s start with something we’re already familiar with. When we create a normal object like this : Vehicle vehicleObj=new Vehicle();
Java creates an object that contains everything defined inside the Vehicle class. You can visualize it like this:
Vehicle Object
┌─────────────────────────────┐
│ brand │
│ model │
│ speed │
│─────────────────────────────│
│ start() │
│ accelerate() │
│ displayStatus() │
└─────────────────────────────┘
Nothing surprising here. The object simply contains everything that belongs to the Vehicle class.
Now let’s create a Car object. Car carObj=new Car();
At first glance, you might think Java creates an object that only contains the members written inside the Car class. Something like this:
Car Object ❌
┌─────────────────────────────┐
│ nitroActivated │
│─────────────────────────────│
│ activateNitro() │
└─────────────────────────────┘
But that’s not what actually happens.
Since Car extends Vehicle, Java first creates the Vehicle part of the object and then adds everything that belongs to the Car class. So the actual object looks like this:
Car Object ✅
┌─────────────────────────────┐
│ Vehicle Part │
│─────────────────────────────│
│ brand │
│ model │
│ speed │
│─────────────────────────────│
│ start() │
│ accelerate() │
│ displayStatus() │
├─────────────────────────────┤
│ Car Part │
│─────────────────────────────│
│ nitroActivated │
│─────────────────────────────│
│ activateNitro() │
└─────────────────────────────┘
Take a moment to observe this structure carefully.
Although we created a **Car object, it also contains the complete Vehicle part because a Car is a Vehicle**.
And this one diagram already answers the first question we had.
How can a
Carobject use fields and methods that aren't even written inside theCarclass?
Because those fields and methods are actually part of the object itself.
They aren’t borrowed from somewhere else. They exist inside every Car object as part of its inherited Vehicle portion.
Now another interesting question comes up.
If every
Carobject containsVehiclepart, does that mean two differentCarorBikeobjects share the same parent data?
Let’s visualize them.
Ferrari (Car Object)
┌──────────────────────────────────┐
│ Vehicle Part │
│──────────────────────────────────│
│ brand = Ferrari │
│ model = 488 GTB │
│ speed = 330 │
│──────────────────────────────────│
│ start() │
│ accelerate() │
│ displayStatus() │
├──────────────────────────────────┤
│ Car Part │
│──────────────────────────────────│
│ nitroActivated = false │
│──────────────────────────────────│
│ activateNitro() │
└──────────────────────────────────┘
Ducati (Bike Object)
┌──────────────────────────────────┐
│ Vehicle Part │
│──────────────────────────────────│
│ brand = Ducati │
│ model = Panigale V4 │
│ speed = 309 │
│──────────────────────────────────│
│ start() │
│ accelerate() │
│ displayStatus() │
├──────────────────────────────────┤
│ Bike Part │
│──────────────────────────────────│
│ swimMode = false │
│──────────────────────────────────│
│ performSwim() │
└──────────────────────────────────┘
Both objects have a Vehicle part, but they are completely independent of each other.
Ferrari’s Vehicle part stores Ferrari's data. Ducati’s Vehicle part stores Ducati's data.
There is no shared brand field, no shared speed field, and no shared model field between these objects.
Every object carries its own inherited state. And this finally explains how same method produces different output.
When we execute: ferrari.displayStatus(); Java executes the displayStatus() method from Ferrari's own Vehicle part, so it prints Ferrari's details.
Likewise, when we execute: ducati.displayStatus(); Java executes the exact same method, but this time on Ducati’s own Vehicle part, so it prints Ducati’s details.
And because every object has its own inherited fields, the output is different.
Classes define the blueprint. Objects hold the data. Inheritance reuses the blueprint, not the object.
How Java Finds Inherited Members
Now let’s answer another question we had earlier.
When we call
ferrari.accelerate(), how does Java know where to find theaccelerate()method?
Java always starts searching from the object’s own class. In our case, the object ferrari is an instance of Car, so Java first checks the Car class.
Car
│
├── accelerate() ❌ Not Found
│
▼
Vehicle
│
└── accelerate() ✅ Found
Since Car doesn't define accelerate(), Java moves to its parent class, Vehicle, finds the method there, and executes it.
On the other hand, if we call: ferrari.activateNitro();, the search stops immediately because that method already exists inside the Car class.
Car
│
└── activateNitro() ✅ Found
That’s the basic lookup rule in inheritance.
Java always searches from the child class first. If the requested member isn’t found, it keeps moving up the inheritance hierarchy until it finds it.
Now here’s a more interesting question.
What happens if both the parent and the child have a method with the same name? Which one will Java execute?
What If the Child Wants to Do Things Differently?
So far, every time we called: ferrari.accelerate();
Java searched the Car class, didn't find the method, moved to the Vehicle class, and executed the inherited implementation.
But what if our game designer comes back with another request?
“A Ferrari shouldn’t accelerate like every other vehicle.”
“Cars should gain 30 km/h, bikes should gain 15 km/h, while trucks should still gain 10 km/h.”
Now we have a problem.
The Vehicle class currently has only one implementation:
public void accelerate() {
speed += 10;
}
If every vehicle uses this method, then every vehicle accelerates in exactly the same way. But that’s not what we want anymore.
We want each child class to have its own version of accelerate(). Fortunately, Java allows us to do exactly that.
A child class can provide its own implementation of an inherited method. This is called Method Overriding. For example, our Car class can override accelerate() like this:
class Car extends Vehicle {
private boolean nitroActivated;
@Override
public void accelerate() {
speed += 30;
System.out.println(brand + " - " + model + " is accelerating at " + speed + " km/h");
}
public void activateNitro() {
nitroActivated = true;
}
}
Notice something interesting. Both Vehicle and Car now have a method named accelerate().
So now when we execute: ferrari.accelerate();
Java starts searching from the Car class. This time, however, it immediately finds an accelerate() method inside Car itself.
Car
│
└── accelerate() ✅ Found
Since the method is found in the child class, Java doesn’t continue searching the parent. It simply executes the child’s implementation.
That’s exactly what Method Overriding means.
The child class provides its own implementation of an inherited method, replacing the parent’s implementation.
The super Keyword
So far, we’ve learned that a child class can inherit members from its parent and even override inherited methods.
But what if the child class wants to access something from its parent?
For example:
- We overrode a method, but we still want to execute the parent’s implementation.
- Both the parent and child have a variable with the same name, and we want to access the parent’s variable.
- We want to initialize the parent part of the object by calling its constructor.
Java provides a single keyword for all these situations: **super**.
What Exactly Is super?
Remember the internal structure of a Car object that we saw earlier?
Car Object
┌──────────────────────────────┐
│ Vehicle Part │
├──────────────────────────────┤
│ Car Part │
└──────────────────────────────┘
Whenever we write super, we're telling Java:
“Don’t work with the child part of this object. Work with its immediate parent part instead.”
You can think of super as a reference that points to the immediate parent portion of the current object.
Because of that, super can be used in three different ways.

Suppose we override accelerate() inside Car.
1. Calling a Parent Method
@Override
public void accelerate() {
super.accelerate();
speed += 20;
}
Here, super.accelerate(); tells Java,
“Execute the
accelerate()method from the parent (Vehicle) class."
This allows us to reuse the parent’s implementation instead of writing everything again.
2. Accessing a Parent Variable
class A{
int a = 10;
}
class B extends A {
int a = 20;
void show(int a){
sopln(a); // 30
sopln(this.a); // 20
sopln(super.a); // 10
}
psvm(){
B ob1.new B();
ob1.show(30);
}
}
3. Calling the Parent Constructor
class Animal {
Animal() {
System.out.println("Animal Constructor");
}
}
class Dog extends Animal {
Dog() {
System.out.println("Dog Constructor 🐶");
}
}
public class Main {
public static void main(String[] args) {
new Dog();
}
}
/** Output :-
Animal Constructor
Dog Constructor 🐶
At first glance, this output looks a little surprising.
We only created a Dog object new Dog();So why was the Animal constructor executed first?
We never explicitly called it. Where did it come from? and Why ?
A child constructor may depend on the parent part’s data that is initialized by the parent constructor. If Java allowed the child constructor to run first, it might try to use data that doesn’t exist yet.
To prevent this, Java always initializes the parent part of the object first and then the child part. That’s why
super()is automatically inserted as the first statement of every child constructor (provided the parent has a no-argument constructor).
But what if the parent doesn’t have a no-argument constructor?
Java can’t automatically call it because it has no idea what values you want to pass. So instead of inserting
super()automatically, we must explicitly call the appropriate parent constructor usingsuper(...).
For example :-
class Employee {
String empId;
Employee(String id) {
this.empId = id;
}
}
class Developer extends Employee {
Developer(int did) {
super("DEV-"+String.valueOf(did));
System.out.println(empId); // DEV-103
}
}
If we don’t explicitly call super(...) here, the code won't compile ❌ because the parent class doesn't have a no-argument constructor that Java can invoke automatically.
Things to Remember
Before we move on, here are a few important rules about super:
- Constructors are not inherited. However, a child constructor can invoke the parent’s constructor using
super(...). **supercan only be used inside a subclass. Since it refers to the **immediate parent of the current object, it doesn't make sense to use it outside a child class.**supercannot be used inside astaticmethod. Static methods belong to the class itself, not to any particular object. Sincesuperrefers to the parent portion of the current object**, there is no object available inside a static method.**superalways refers to the immediate parent class.** Even if there are multiple levels of inheritance,supercan only access members of the direct parent, not grandparents or ancestors further up the hierarchy. (This follows directly from the definition ofsuperas the immediate parent reference.)- The first statement inside every constructor must be either
super(...)orthis(...)—never both.
Dog Object
┌──────────────────────────────┐
│ Animal Part │
│──────────────────────────────│
│ name │
│ sound() │
├──────────────────────────────┤
│ Dog Part │
│──────────────────────────────│
│ name │
│ sound() │
└──────────────────────────────┘
Inside Dog Class,
this.name -> [use Dog Part] &
super.name -> [use Animal Part]
When to Use Inheritance
Inheritance is powerful, but it should be used intentionally, only when it truly models a real-world relationship. Getting this decision wrong early in your design leads to code that’s hard to change, hard to test, and hard to reason about.
Here’s a practical checklist.
Use inheritance when:
- There is a clear “is-a” relationship (e.g.,
Dog is an Animal,Car is a Vehicle). If you can't say "X is a Y" naturally, inheritance is probably the wrong tool. These relationships belong in composition. - The parent class defines common behavior or data that children should share. For example, all vehicles have a
startEngine()method, so putting it in the parent avoids duplicating it across every vehicle type. - The child class does not violate the behavior expected from the parent. If someone has a
Vehiclereference pointing to anElectricCar, everyVehiclemethod should still work as expected. - You want to promote code reuse through shared logic and structure, and the hierarchy is shallow (2–3 levels at most).
Avoid inheritance when:
- The relationship is “has-a” or “uses-a” rather than “is-a”. A
Carhas anEngine, it is not anEngine. APrinteruses aLogger, it is not aLogger. - You want to combine behaviors from multiple sources dynamically. Inheritance locks you into a single parent at compile time, while composition lets you mix and match components freely.
- You need runtime flexibility to swap behaviors. With composition, you can inject different implementations (swap a
FileLoggerfor aConsoleLogger). With inheritance, the parent relationship is fixed. - You want to avoid tight coupling between child and parent internals. Changes to a parent class ripple down to every child in the hierarchy, which is risky in large codebases.
When in doubt, start with composition. You can always refactor toward inheritance later if a genuine “is-a” hierarchy emerges. Going the other direction, untangling a deep inheritance tree into composition, is much harder.
Practical Example: Notification System
Let’s apply inheritance to a completely different domain to show that these patterns aren’t limited to vehicles. Imagine you’re building a notification system that can send messages through different channels: email, SMS, and push notifications.
All notification types share common properties: a recipient, a message, and a timestamp. They all need a formatHeader() method that produces a consistent header format. But the send() method works differently for each channel, email needs a subject line, SMS has a character limit, and push notifications have a device token and priority level.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
class Notification {
protected String recipient;
protected String message;
protected String timestamp;
public Notification(String recipient, String message) {
this.recipient = recipient;
this.message = message;
this.timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
public String formatHeader() {
return "[" + timestamp + "] To: " + recipient;
}
public void send() {
System.out.println(formatHeader());
System.out.println("Message: " + message);
}
}
class EmailNotification extends Notification {
private String subject;
public EmailNotification(String recipient, String message, String subject) {
super(recipient, message);
this.subject = subject;
}
@Override
public void send() {
System.out.println(formatHeader());
System.out.println("Subject: " + subject);
System.out.println("Body: " + message);
System.out.println("Status: Email delivered");
}
}
class SMSNotification extends Notification {
private String phoneNumber;
private static final int MAX_LENGTH = 160;
public SMSNotification(String recipient, String message, String phoneNumber) {
super(recipient, message);
this.phoneNumber = phoneNumber;
}
@Override
public void send() {
System.out.println(formatHeader());
System.out.println("Phone: " + phoneNumber);
String smsBody = message.length() > MAX_LENGTH
? message.substring(0, MAX_LENGTH - 3) + "..."
: message;
System.out.println("SMS: " + smsBody);
System.out.println("Status: SMS sent (" + smsBody.length() + "/" + MAX_LENGTH + " chars)");
}
}
class PushNotification extends Notification {
private String deviceToken;
private String priority;
public PushNotification(String recipient, String message,
String deviceToken, String priority) {
super(recipient, message);
this.deviceToken = deviceToken;
this.priority = priority;
}
@Override
public void send() {
System.out.println(formatHeader());
System.out.println("Device: " + deviceToken.substring(0, 8) + "...");
System.out.println("Priority: " + priority);
System.out.println("Alert: " + message);
System.out.println("Status: Push notification delivered");
}
}
public class Main {
public static void main(String[] args) {
EmailNotification email = new EmailNotification(
"alice@example.com", "Your order has been shipped!", "Order Update");
email.send();
System.out.println();
SMSNotification sms = new SMSNotification(
"Bob", "Your verification code is 482910.", "+1-555-0123");
sms.send();
System.out.println();
PushNotification push = new PushNotification(
"Charlie", "New message from Alice",
"d8a3f4b2c1e5a9b7", "high");
push.send();
}
}
Why This Design Works
- Shared logic is written once. The
recipient,message, andtimestampfields are defined inNotification. TheformatHeader()method is inherited by all three notification types, producing a consistent header format across email, SMS, and push. If you want to change the timestamp format, you change one method. - Each child encapsulates channel-specific complexity.
SMSNotificationhandles the 160-character limit.PushNotificationmanages device tokens and priority.EmailNotificationadds a subject line. None of these details leak into the parent or into each other. - Adding a new channel is simple. Need Slack notifications? Create
SlackNotification extends Notification, add awebhookUrlfield, overridesend(). No existing code changes.
That’s it. Thank you so much for reading!
메타데이터
- post_id
- 4228bbd1a25e
- slug
- 3-1-the-oop-core-designing-better-classes-with-inheritance-4228bbd1a25e
- url
- https://medium.com/@hiimvikash/3-1-the-oop-core-designing-better-classes-with-inheritance-4228bbd1a25e
- canonical_url
- https://medium.com/@hiimvikash/3-1-the-oop-core-designing-better-classes-with-inheritance-4228bbd1a25e
- author_url
- https://medium.com/@hiimvikash
- status
- ok
- fetched_at
- 2026-08-11 18:06:04