Java OOP rests on four pillars: encapsulation, inheritance, polymorphism and abstraction. Encapsulation protects an object's data. Inheritance lets one class reuse another. Polymorphism lets the same call behave differently depending on the actual object. Abstraction lets code depend on what something does rather than how it does it. Each pillar solves a specific problem, and each one causes new problems when you overuse it.
This article assumes you already know how to write a class, a constructor and a method. If not, start with Java classes, objects and constructors. All examples compile on Java 17+.
Encapsulation in Java
Encapsulation means an object keeps its fields private and exposes only the operations that make sense. Other code can't reach in and put the object into an invalid state. It has to go through methods the class controls.
A closely related idea is information hiding: callers shouldn't need to know how the class works inside. If they only use its public methods, you can change the internals later without breaking them.
public class BankAccount {
private final String owner;
private long balance; // in the smallest currency unit, e.g. satang
public BankAccount(String owner) {
this.owner = Objects.requireNonNull(owner);
}
public String getOwner() { return owner; }
public long getBalance() { return balance; }
public void deposit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
balance += amount;
}
public void withdraw(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
if (amount > balance) throw new IllegalStateException("insufficient funds");
balance -= amount;
}
}Notice there is no setBalance(). The balance changes only through deposit and withdraw, and both enforce the rules.
Getters and setters
Methods that read a field are getters (accessors). Methods that change one are setters (mutators). They are the standard way to expose encapsulated data, but they aren't encapsulation by themselves. A class with a private field plus a public getter and setter for it behaves almost like a public field. Use these guidelines:
- Add a getter only when outside code actually needs the value.
- Add a setter only when changing the value directly is a valid operation. Validate the input inside it.
- Prefer methods named after real operations (
withdraw,rename,cancel) over generic setters. - Make fields
finalwhen they shouldn't change, and skip the setter entirely.
Access modifiers table
Java has three access keywords plus a fourth level you get by writing no keyword at all:
| Modifier | Same class | Same package | Subclass in another package | Anywhere | UML |
|---|---|---|---|---|---|
public | Yes | Yes | Yes | Yes | + |
protected | Yes | Yes | Yes | No | # |
| (none), package-private | Yes | Yes | No | No | ~ |
private | Yes | No | No | No | - |
A detail beginners often miss: protected also grants access to every class in the same package, not only to subclasses. Start with private and widen access only when you have a reason.
Inheritance: extends and super
Inheritance lets a subclass (child) build on a superclass (parent) using extends. The subclass gets the parent's accessible fields and methods and can add its own.
public class Person {
protected final String name;
public Person(String name) {
this.name = name;
}
public String introduce() {
return "Hi, I'm " + name;
}
}
public class Student extends Person {
private final String studentId;
public Student(String name, String studentId) {
super(name); // call the Person constructor
this.studentId = studentId;
}
@Override
public String introduce() {
return super.introduce() + ", student " + studentId;
}
}Rules worth knowing:
- A class can
extendonly one class. Java has no multiple inheritance of classes, but a class can implement many interfaces. - Constructors aren't inherited. A subclass constructor must call a parent constructor with
super(...), and the arguments must match one of the parent's constructors. - If you don't write
super(...), the compiler insertssuper()with no arguments. If the parent has no no-arg constructor, you get a compile error, a common surprise. - Before Java 25,
super(...)must be the first statement in the constructor. Java 25 relaxes this: statements such as argument validation can come before it, as long as they don't read from the object being constructed. super.method()calls the parent's version of an overridden method, asintroduce()does above.privatemembers aren't accessible in the subclass, even though they exist inside the object.- A
finalclass can't be extended, and afinalmethod can't be overridden.
Use inheritance only when the subclass really is a kind of the parent. A Student is a Person. A Car isn't an Engine. When the relationship is "has a", give the class a field of that type instead. This is composition, and it keeps class hierarchies shallow.
Polymorphism: one call, many forms
Polymorphism means "many forms". A variable of a supertype can hold any subtype, and when you call a method, Java runs the version that belongs to the actual object at runtime. This is called dynamic dispatch.
public interface Shape {
double area();
}
public record Circle(double radius) implements Shape {
@Override
public double area() { return Math.PI * radius * radius; }
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() { return width * height; }
}List<Shape> shapes = List.of(new Circle(1), new Rectangle(2, 3));
for (Shape s : shapes) {
System.out.printf("%s -> %.2f%n", s, s.area());
}
// Circle[radius=1.0] -> 3.14
// Rectangle[width=2.0, height=3.0] -> 6.00The loop doesn't know or care which shapes it has. Adding a Triangle requires no change to the loop. The same thing happens with inheritance: Person p = new Student("Kasinphat", "6501234"); p.introduce() runs Student's version.
Method overriding rules
Overriding means a subclass provides its own implementation of an inherited method. For it to be a valid override:
- The name and parameter list must match exactly.
- The return type must be the same, or a subtype of it (a covariant return).
- Access can't be more restrictive. A
publicmethod can't becomeprotected. - It can't throw new or broader checked exceptions than the original.
static,finalandprivatemethods can't be overridden.
Always add @Override. It makes the compiler check these rules for you.
Overriding vs overloading
| Overriding | Overloading | |
|---|---|---|
| Where | Subclass redefines a parent method | Same class, same name |
| Parameters | Must be identical | Must differ |
| Decided at | Runtime (actual object type) | Compile time (declared argument types) |
| Purpose | Change behavior per subtype | Offer convenient variants of one operation |
A classic bug mixes the two up. Writing public boolean equals(Book other) overloads equals instead of overriding equals(Object), so collections never call it. With @Override on it, the compiler rejects it immediately.
Abstraction: depend on what, not how
Abstraction means exposing the essential operations and hiding the implementation details. In Java you express it with interfaces and abstract classes. The mechanics of both are covered in the classes and objects guide. Here the question is how to use them in design.
public record Order(String id, long total) {}
public record PaymentResult(boolean success, String reference) {}
public interface PaymentGateway {
PaymentResult charge(String orderId, long amount);
}
public class CheckoutService {
private final PaymentGateway gateway;
public CheckoutService(PaymentGateway gateway) {
this.gateway = gateway;
}
public PaymentResult checkout(Order order) {
return gateway.charge(order.id(), order.total());
}
}CheckoutService doesn't know whether the gateway is a card processor, PromptPay or a fake used in tests. You can swap implementations without touching checkout logic. This is the idea behind hexagonal architecture's ports and adapters. You already use it every day when you write List<String> names = new ArrayList<>(); and program against List.
When to choose which:
- Interface: the default choice for defining a capability. A class can implement several, and unrelated classes can share one.
- Abstract class: when subclasses share real state or code, such as common fields, a constructor, or a template method that calls abstract steps.
- Neither: when only one implementation exists and none is likely. An interface nobody else implements adds indirection without benefit.
How the four pillars of Java OOP fit together
| Pillar | Problem it solves | Main Java tools |
|---|---|---|
| Encapsulation | Uncontrolled changes to an object's data | private fields, validating methods |
| Inheritance | Duplicated code between related types | extends, super |
| Polymorphism | if/else chains that check an object's type | Overriding, interfaces, dynamic dispatch |
| Abstraction | Code that depends on implementation details | Interfaces, abstract classes |
In the checkout example, the gateway hides its HTTP client and API keys (encapsulation). CheckoutService sees only PaymentGateway (abstraction). Each gateway class supplies its own charge (polymorphism). If several gateways share retry logic, an abstract base class can hold it (inheritance).
FAQ
What are the four pillars of OOP in Java?
Encapsulation, inheritance, polymorphism and abstraction. Some courses teach three and treat abstraction as part of encapsulation, but most Java material lists all four.
What is the difference between encapsulation and abstraction?
Encapsulation is about protecting data inside one class through access control. Abstraction is about which operations a type exposes to the rest of the system. Encapsulation hides the fields. Abstraction hides which implementation you're talking to.
Does Java support multiple inheritance?
Not for classes. A class can extend only one class. It can implement any number of interfaces, and interfaces can provide default methods.
Can a protected member be accessed from the same package?
Yes. protected includes package access, so any class in the same package can use it. Subclasses in other packages can use it too.
Is overloading a form of polymorphism?
Some textbooks call it compile-time polymorphism. In everyday Java usage, "polymorphism" usually refers to overriding and dynamic dispatch at runtime.
Putting Java OOP into practice
- Start every field as
private, and add getters and setters only when you need them. - Replace generic setters with methods named after real operations that enforce your rules.
- Use
extendsonly for true "is a" relationships, and prefer composition otherwise. - Put
@Overrideon every overriding method. - Accept interfaces in constructors and parameters so implementations can be swapped and tested.
- Keep hierarchies shallow. If you need three levels to understand one class, redesign.
The same principles apply when you build UIs, for example Swing components and event listeners. If you need help designing or reviewing an object-oriented codebase, Vectorkub does that kind of work.
