When I first began experimenting with Java, I considered it a more sophisticated version of the simple procedural programming I was used to. The methods I wrote were long, I kept everything in static functions, and kept wondering why experienced developers kept talking about those mysterious “objects” and “classes.”
After a while I was forced to look into object-oriented programming (OOP) and that’s when everything started to make sense to me. OOP isn’t a feature of Java, it’s basically the foundation as it makes it powerful enough to build large applications that are easy to maintain.
In this article I’ll try to walk you through the basic principles of OOP in Java and share some examples along the way hoping to help you embrace best practices that will make you a better Java developer.
Why Object-Oriented Programming is Crucial in Java
Java was designed as an object-oriented language, so its entire architecture revolves around objects and classes. This is why Java is preferred by many developers working on large-scale applications where team collaboration and code reusability are essential.
Team collaboration is more manageable when different developers can work on different classes and interfaces without interfering with someone else’s work, and dividing responsibilities is easier when there are clear boundaries between objects.
Code reusability is a big benefit of OOP. It’s not necessary to copy and paste pieces of code when you need to repeat certain behaviors. Instead, if you create a class, you can reuse it as many times as you need throughout your application and if there’s a bug, you only have to fix it in one place.
Another benefit is how intuitive solving real-world problems becomes when you think in terms of objects. Let’s say that you’re working on a banking application. In such a case, you’d have Account objects, Customer objects, and Transaction objects, for example. Mapping code with real-world concepts makes your applications easier to understand and maintain.
And if you ever need to scale your application, you simply need to add new classes or extend existing ones without breaking the functionality that already exists.
Core Principles of Object-Oriented Programming
If you want to write effective Java code, you must understand four fundamental concepts: encapsulation, inheritance, polymorphism, and abstraction.
These concepts aren’t just academic, they are actual tools that help solve real programming problems.
1. Encapsulation:
You can think of encapsulation as bundling data and the methods that operate on that data together in a “capsule” so that the internal implementation details are hidden from the code outside.
// Encapsulation to keep data private and expose safe methods.
public class BankAccount {
// Private fields, hidden from outside code.
private double balance; // For real money use BigDecimal or store cents as int/long.
private String accountNumber;
// Constructor sets up a valid object.
public BankAccount(String accountNumber, double initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
// Public method, safe way to add money.
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
// else ignore invalid amounts
}
// Public method, safe way to remove money.
public boolean withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return true;
}
return false; // not enough funds or invalid amount
}
// Read only access to balance.
public double getBalance() {
return balance;
}
}2. Inheritance:
With inheritance, we can easily reuse code and establish hierarchies. This basically means that if we have a parent class, we can make the child class inherit properties and methods from its parent, which is super useful, among other things, to avoid falling into a big no-no in programming: “Don’t Repeat Yourself,” or its acronym DRY.
// Base class, shares common fields and behavior with child classes.
public class Vehicle {
// protected means child classes can see these, but other classes cannot.
protected String brand;
protected int year;
public Vehicle(String brand, int year) {
this.brand = brand;
this.year = year;
}
public void start() {
System.out.println("Vehicle starting...");
}
}
// Child class, a specific kind of Vehicle that customizes behavior.
public class Car extends Vehicle {
private int doors;
public Car(String brand, int year, int doors) {
super(brand, year); // call to parent constructor
this.doors = doors;
}
@Override
public void start() {
System.out.println("Car engine starting...");
}
public void honk() {
System.out.println("Beep beep!");
}
}3. Polymorphism:
Polymorphism can be a bit more difficult to understand if it’s your first time dealing with OOP.
Imagine that you have a box of toy animals and that you can give one simple instruction (“make sound”) to any toy. When you do, the dog barks, the cat meows, and the cow moos. If we implement something like this using polymorphism, we don’t need a special instruction for each toy. Instead, we give a single common instruction, and each toy does its own thing.
// Same method name, different behavior depending on the actual object.
class Animal {
void speak() { System.out.println("..."); } // default or placeholder
}
class Dog extends Animal {
@Override void speak() { System.out.println("Woof"); }
}
class Cat extends Animal {
@Override void speak() { System.out.println("Meow"); }
}
public class MainPolymorphism {
// Works with the general type Animal, not a specific animal.
static void makeItSpeak(Animal a) {
a.speak(); // Java calls the correct speak() at run time
}
public static void main(String[] args) {
makeItSpeak(new Dog()); // prints Woof
makeItSpeak(new Cat()); // prints Meow
}
}4. Abstraction:
This principle can also be a bit difficult to understand at first so maybe another analogy can help. Think about a car. To make it move, you only have to press the gas pedal. You don’t need to know how the engine mixes fuel and air or how the spark plugs fire. You have simplified controls to operate the car and all the complicated machinery works out of your sight.
In Java, we can (and should) do something similar. With abstraction we hide complex implementation details and we expose only the necessary features through interfaces and abstract classes.
// Interface says what can be done, classes decide how.
interface Printer {
void print(String text); // contract, no details here
}
// One way to print.
class LaserPrinter implements Printer {
public void print(String text) {
System.out.println("Laser: " + text);
}
}
// Another way to print.
class InkjetPrinter implements Printer {
public void print(String text) {
System.out.println("Inkjet: " + text);
}
}
public class MainAbstraction {
// Code depends on the interface, easy to swap implementations.
static void sendToPrinter(Printer p) {
p.print("Hello");
}
public static void main(String[] args) {
sendToPrinter(new LaserPrinter()); // Laser: Hello
sendToPrinter(new InkjetPrinter()); // Inkjet: Hello
}
}Writing OOP Code in Java
Once you understand how to properly structure classes, create objects, and use interfaces, you’ll be surprised how much more fluid and predictable the development process becomes.
You will still have to look things up from time to time, that’s completely normal, but your mind will work in a different way. You’ll use abstractions, let names reflect intent, and you’ll use small focused classes. You’ll also develop an intuition to split a class when it starts doing too much and to introduce an interface when two parts know too much about each other.
1. Classes and Objects:
Classes are blueprints for objects, defining their properties and behaviors. Objects are instances of classes that hold actual data.
You can think of a class as a recipe that lists ingredients and steps, properties and behaviors, while an object is a cake made from that recipe, a real thing that has its own data and can do the actions the recipe describes.
// A class is a blueprint, an object is a real thing made from that blueprint.
public class Student {
// Fields, also called instance variables.
private String name;
private int id;
private double gpa;
// Constructor sets required fields and valid defaults.
public Student(String name, int id) {
this.name = name;
this.id = id;
this.gpa = 0.0;
}
// Method to update behavior or state safely.
public void updateGPA(double newGPA) {
if (newGPA >= 0.0 && newGPA <= 4.0) { // simple GPA range check
this.gpa = newGPA;
}
}
public String getStudentInfo() {
return "Student: " + name + " (ID: " + id + ", GPA: " + gpa + ")";
}
// Basic getters to read data.
public String getName() { return name; }
public int getId() { return id; }
public double getGpa() { return gpa; }
}
// Small demo to create and use a Student.
public class StudentDemo {
public static void main(String[] args) {
Student student1 = new Student("Alice Johnson", 12345);
student1.updateGPA(3.7);
System.out.println(student1.getStudentInfo());
}
}2. Interfaces and Abstract Classes:
The best way I can think of describing interfaces is as lists of available actions without details on how to do them. They’re like a remote with labeled buttons. The labels are fixed and each device decides how the button works inside.
Abstract classes are like partly built bases. They can actually include some real code that subclasses can extend, and they can also leave some methods unfinished for their children to fill in.
// Interface, a contract with method signatures.
public interface Shape {
double calculateArea();
double calculatePerimeter();
}
// Abstract class, can have fields and some shared logic.
// It can leave some methods abstract for children to complete.
public abstract class Polygon implements Shape {
protected int sides;
public Polygon(int sides) {
this.sides = sides;
}
public int getSides() {
return sides;
}
// This class still does not implement calculatePerimeter,
// so it remains abstract which is fine.
public abstract double calculateArea();
}
// A concrete class that fills in the missing details.
public class Rectangle extends Polygon {
private double length;
private double width;
public Rectangle(double length, double width) {
super(4); // a rectangle has 4 sides
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
@Override
public double calculatePerimeter() {
return 2 * (length + width);
}
}3. Constructors and Method Overloading:
To understand constructors you can imagine you’re opening a new board game and putting the pieces in place so it’s ready to play. This basically means that constructors are the setup step that runs when you make a new object.
Method overloading allows us to have several methods with the same name but with different parameters. A good analogy to explain this is that of a door that can be opened with a physical key, a contactless keycard, or a code. The action is the same (opening the door) but we achieve that with different inputs.
// Constructors set up new objects. Overloading lets you have methods with the same name
// but different parameters, which is nice for flexibility.
class Greeter {
String who;
// Two ways to build a Greeter, with and without a name.
Greeter() { this("World"); } // default value
Greeter(String who) { this.who = who; }
// Same method name, different parameters, this is overloading.
void greet() {
System.out.println("Hello, " + who);
}
void greet(String punctuation) {
System.out.println("Hello, " + who + punctuation);
}
}
public class MainOverloading {
public static void main(String[] args) {
Greeter g1 = new Greeter(); // uses default constructor
Greeter g2 = new Greeter("Alice"); // uses the other constructor
g1.greet(); // Hello, World
g2.greet(); // Hello, Alice
g2.greet("!"); // Hello, Alice!
}
}Real-World Example: Library Management System
Let me show you how OOP principles come together in a practical library management system that demonstrates real-world application design.
For this small code example, we’ll code the simple idea that a book can be on the shelf, or in someone’s hands and that a member can borrow at most one book at a time.
// Idea: A Book can be on the shelf or checked out. A Member can hold one book.
public class MainLibrary {
public static void main(String[] args) {
Book b1 = new Book("B1", "Clean Code");
Book b2 = new Book("B2", "Effective Java");
Member ana = new Member("Ana");
System.out.println(b1); // Book[id=B1, title=Clean Code, out=false]
System.out.println("Borrow b1: " + ana.borrow(b1)); // true
System.out.println(b1); // ... out=true
System.out.println("Borrow b1 again: " + ana.borrow(b1)); // false, already out
ana.returnBook(); // give it back
System.out.println(b1); // ... out=false
System.out.println("Borrow b2: " + ana.borrow(b2)); // true
}
}
// Simple data class with safe state changes.
class Book {
private final String id;
private final String title;
private boolean checkedOut; // false means on shelf, true means out
Book(String id, String title) {
this.id = id;
this.title = title;
this.checkedOut = false; // new books start on the shelf
}
boolean checkOut() {
if (checkedOut) return false; // already out
checkedOut = true;
return true;
}
void giveBack() {
checkedOut = false;
}
boolean isCheckedOut() { return checkedOut; }
@Override
public String toString() {
return "Book[id=" + id + ", title=" + title + ", out=" + checkedOut + "]";
}
}
// A member can hold at most one book at a time.
class Member {
private final String name;
private Book borrowed; // null means empty hands
Member(String name) { this.name = name; }
boolean borrow(Book b) {
if (borrowed != null) return false; // already holding one
if (b == null) return false; // defensive check
if (b.checkOut()) {
borrowed = b;
return true;
}
return false; // book was not available
}
void returnBook() {
if (borrowed != null) {
borrowed.giveBack();
borrowed = null;
}
}
}Best Practices for Java OOP
I like to think of good OOP habits like driving rules. If you and everyone else use turn signals, stop at red lights, and keep a safe distance, you can drive anywhere without problems because everyone follows the same simple rules.
Best practices in Java are those shared rules. If you use clear names, small methods, private data, tests that act like seatbelts, and reviews that act like road signs, your code will be easy to understand and maintain. It will also be predictable for the next person who reads it which is essential for team work.
1. Code Reuse and Modularity:
Always make sure to design classes that are reusable and focus on single responsibilities. Remember, modularity means breaking a big problem into small parts that each do one clear job.
// Small focused class, easy to reuse across the app.
// Just a quick example. Real email validation is more complex.
public class EmailValidator {
public static boolean isValid(String email) {
return email != null && email.contains("@") && email.contains(".");
}
}
// Another class uses EmailValidator without duplicating logic.
public class User {
private String email;
public void setEmail(String email) {
if (EmailValidator.isValid(email)) {
this.email = email;
} else {
throw new IllegalArgumentException("Invalid email format");
}
}
public String getEmail() { return email; }
}
// Composition over inheritance. A Car "has an" Engine and a GPS.
// Provide tiny interfaces so the example is self contained.
interface Engine { void start(); }
interface GPS { void navigateTo(String destination); }
public class Car {
private final Engine engine;
private final GPS gps;
public Car(Engine engine, GPS gps) {
this.engine = engine; // pass in collaborators, easy to swap or test
this.gps = gps;
}
public void start() { engine.start(); }
public void goTo(String destination) { gps.navigateTo(destination); }
}2. Proper Encapsulation:
Always use encapsulation to use appropriate access modifiers and provide controlled access to internal state. When in doubt, think of encapsulation as a vending machine. You press a button, it gives you a drink but you never get to reach the hidden complex gears inside.
// Encapsulate state with private fields and safe methods.
public class Temperature {
private double celsius;
public Temperature(double celsius) {
setCelsius(celsius); // reuse the validation in one place
}
// Temperature cannot go below absolute zero, simple rule inside the class.
public void setCelsius(double celsius) {
if (celsius < -273.15) {
throw new IllegalArgumentException("Temperature below absolute zero");
}
this.celsius = celsius;
}
public double getCelsius() { return celsius; }
public double getFahrenheit() { return celsius * 9 / 5 + 32; }
public double getKelvin() { return celsius + 273.15; }
}3. Interface Design:
You should also always do your best to create clean, focused interfaces that define clear contracts. Remember, interfaces are lists of things that can be done but don’t explain how to do them. The classes that take the job promise to do them, in their own way.
// A focused interface with a clear purpose and clear method names.
import java.awt.Color;
public interface Drawable {
void draw();
void setColor(Color color);
}
// Split interfaces can help keep responsibilities small.
public interface Moveable {
void move(int x, int y);
}
public interface Resizable {
void resize(double factor);
}
// Class implements only what it needs. Simple demo version.
public class Circle implements Drawable, Moveable {
private int x, y;
private int radius;
private Color color = Color.BLACK;
public Circle(int x, int y, int radius) {
this.x = x; this.y = y; this.radius = radius;
}
// Drawable
public void draw() { System.out.println("Drawing circle at (" + x + "," + y + ")"); }
public void setColor(Color color) { this.color = color; }
// Moveable
public void move(int newX, int newY) { this.x = newX; this.y = newY; }
}⚠️ Common Pitfalls to Avoid:
Even with solid OOP basics, it’s easy to slip into patterns that make code harder to change, test, and understand. Let’s take a look at some common mistakes that you should understand to write better code from the start.
1. Overuse of Inheritance:
Always favor composition over inheritance when objects have a “has-a” rather than “is-a” relationship. For example, a car has an engine, it’s not an engine, so composition would be more appropriate in such a case.
import java.util.ArrayList;
import java.util.List;
// Poor example: Employee "is a" list, which is wrong in the real world.
public class EmployeeBad extends ArrayList<String> {
// This creates a fake "is a" relationship just to reuse list methods.
}
// Better example: Employee "has a" list of skills, composition makes sense here.
public class Employee {
private final List<String> skills = new ArrayList<>();
public void addSkill(String skill) { skills.add(skill); }
public List<String> getSkills() { return new ArrayList<>(skills); } // return a copy
}2. Tight Coupling:
Avoid creating classes that depend too heavily on specific implementations of other classes. To illustrate this, think of a phone charger that is soldered to the wall. Sure, it works when you are next to it, but you can’t take it anywhere and fixing it would be a pain.
// Poor: Tight coupling
public class Order {
private MySQLDatabase database; // Coupled to specific database
public void save() {
database.save(this);
}
}
// Better: Loose coupling with interfaces
public class Order {
private Database database; // Interface dependency
public Order(Database database) {
this.database = database;
}
public void save() {
database.save(this);
}
}3. Breaking Encapsulation:
Avoid exposing internal implementation details that should remain hidden. Going back to the vending machine example, if someone could open a side panel and mess with the mechanism inside, the machine would break. In code, if you break encapsulation, you’re basically letting other pieces of code reach in and mess around with the inner gears.
// Poor: Exposing internal structure
public class ShoppingCart {
public List<Item> items; // Direct access to internal list
}
// Better: Controlled access
public class ShoppingCart {
private List<Item> items;
public ShoppingCart() {
this.items = new ArrayList<>();
}
public void addItem(Item item) {
items.add(item);
}
public List<Item> getItems() {
return new ArrayList<>(items); // Return defensive copy
}
}Conclusion
Object-oriented programming in Java is about thinking in terms of real-world models, creating maintainable code, and building applications that can grow and evolve over time.
The key to mastering OOP in Java is to understand, practice and consistently apply the principles of encapsulation, inheritance, polymorphism, and abstraction. I strongly encourage you to start with simple examples, focus on creating clean interfaces, and consider how your design decisions will affect future maintenance and extension of your code.
Remember that good OOP design often means choosing simplicity. Try to avoid “clever” code as it can easily become cryptic, even with proper comments.
If you feel like you are ready to dive deeper into Java development, Udacity’s Java Programming Nanodegree provides comprehensive training in Java OOP principles and enterprise application development. If you’re interested in web development, take a look at the Java Web Developer Nanodegree which shows how to apply OOP principles in building scalable web applications. And if you’re just getting started with programming, the Intro to Programming Nanodegree covers fundamental programming concepts including OOP basics but with Python.


