Object-Oriented Design Principles for Java Beginners

Object-Oriented Design Principles for Java Beginners

Look, here’s the thing about object-oriented programming in Java - it’s not just about classes and objects. That’s like saying cooking is just about ingredients. The real magic happens when you understand the principles that make your code actually work together nicely.

I’ve been teaching Java for years, and I’ve seen countless students write code that “works” but is a nightmare to maintain. Six months later, they can’t even understand their own code! Don’t let that be you.

The five principles we’re covering today aren’t academic theory - they’re battle-tested guidelines that will save your sanity and make you a better programmer. Trust me on this.

New to Java objects?

Make sure you’re comfortable with basic classes and methods first. Check out Java Fundamentals if you need a refresher.

The Big Five OOD Principles

These five principles form the foundation of good object-oriented design. Master these, and you’ll write code that your future self (and your teammates) will thank you for.

1. Single Responsibility Principle (SRP)

The Rule: A class should have one, and only one, reason to change.

Here’s what I mean by this. Look at this mess of a class:

 1// BAD: This class is doing way too much
 2public class Employee {
 3    private String name;
 4    private double salary;
 5    private String department;
 6    
 7    public Employee(String name, double salary, String department) {
 8        this.name = name;
 9        this.salary = salary;
10        this.department = department;
11    }
12    
13    // Employee data management
14    public String getName() { return name; }
15    public double getSalary() { return salary; }
16    public String getDepartment() { return department; }
17    
18    // Database operations - PROBLEM!
19    public void saveToDatabase() {
20        System.out.println("Saving " + name + " to database...");
21        // Database code here
22    }
23    
24    public void deleteFromDatabase() {
25        System.out.println("Deleting " + name + " from database...");
26        // Database deletion code
27    }
28    
29    // Payroll calculations - ANOTHER PROBLEM!
30    public double calculateTax() {
31        if (salary > 100000) {
32            return salary * 0.3;
33        } else {
34            return salary * 0.2;
35        }
36    }
37    
38    // Report generation - YET ANOTHER PROBLEM!
39    public void generatePayslip() {
40        System.out.println("=== PAYSLIP ===");
41        System.out.println("Employee: " + name);
42        System.out.println("Salary: $" + salary);
43        System.out.println("Tax: $" + calculateTax());
44        System.out.println("Net Pay: $" + (salary - calculateTax()));
45    }
46}

See the problem? This class is trying to be an employee data holder, a database manager, a tax calculator, AND a report generator. If the tax rates change, you have to modify this class. If the database schema changes, you modify this class. If the payslip format changes… you get the idea.

Here’s how to fix it:

 1// GOOD: Each class has one clear responsibility
 2public class Employee {
 3    private String name;
 4    private double salary;
 5    private String department;
 6    
 7    public Employee(String name, double salary, String department) {
 8        this.name = name;
 9        this.salary = salary;
10        this.department = department;
11    }
12    
13    // Only employee data management
14    public String getName() { return name; }
15    public double getSalary() { return salary; }
16    public String getDepartment() { return department; }
17}
18
19public class EmployeeRepository {
20    // Only database operations
21    public void save(Employee employee) {
22        System.out.println("Saving " + employee.getName() + " to database...");
23        // Database code here
24    }
25    
26    public void delete(Employee employee) {
27        System.out.println("Deleting " + employee.getName() + " from database...");
28    }
29}
30
31public class TaxCalculator {
32    // Only tax calculations
33    public double calculateTax(Employee employee) {
34        double salary = employee.getSalary();
35        if (salary > 100000) {
36            return salary * 0.3;
37        } else {
38            return salary * 0.2;
39        }
40    }
41}
42
43public class PayslipGenerator {
44    private TaxCalculator taxCalculator;
45    
46    public PayslipGenerator(TaxCalculator taxCalculator) {
47        this.taxCalculator = taxCalculator;
48    }
49    
50    // Only report generation
51    public void generatePayslip(Employee employee) {
52        double tax = taxCalculator.calculateTax(employee);
53        double netPay = employee.getSalary() - tax;
54        
55        System.out.println("=== PAYSLIP ===");
56        System.out.println("Employee: " + employee.getName());
57        System.out.println("Salary: $" + employee.getSalary());
58        System.out.println("Tax: $" + tax);
59        System.out.println("Net Pay: $" + netPay);
60    }
61}

Now each class has one job and one reason to change. Beautiful, right?

2. Open/Closed Principle (OCP)

The Rule: Classes should be open for extension, but closed for modification.

This sounds fancy, but it’s actually simple. You should be able to add new behavior to your code without changing existing code. Here’s why this matters.

Bad approach - every time you need a new shape, you modify existing code:

 1// BAD: Every new shape requires modifying this class
 2public class AreaCalculator {
 3    public double calculateArea(Object shape, String shapeType) {
 4        switch (shapeType) {
 5            case "rectangle":
 6                Rectangle rect = (Rectangle) shape;
 7                return rect.getWidth() * rect.getHeight();
 8            case "circle":
 9                Circle circle = (Circle) shape;
10                return Math.PI * circle.getRadius() * circle.getRadius();
11            // What happens when we need a triangle? We modify this method!
12            default:
13                throw new IllegalArgumentException("Unknown shape type");
14        }
15    }
16}

Good approach - new shapes extend the system without changing existing code:

 1// GOOD: Use polymorphism to make code extensible
 2public abstract class Shape {
 3    public abstract double calculateArea();
 4}
 5
 6public class Rectangle extends Shape {
 7    private double width;
 8    private double height;
 9    
10    public Rectangle(double width, double height) {
11        this.width = width;
12        this.height = height;
13    }
14    
15    @Override
16    public double calculateArea() {
17        return width * height;
18    }
19    
20    public double getWidth() { return width; }
21    public double getHeight() { return height; }
22}
23
24public class Circle extends Shape {
25    private double radius;
26    
27    public Circle(double radius) {
28        this.radius = radius;
29    }
30    
31    @Override
32    public double calculateArea() {
33        return Math.PI * radius * radius;
34    }
35    
36    public double getRadius() { return radius; }
37}
38
39// Adding a new shape? No problem - no existing code changes!
40public class Triangle extends Shape {
41    private double base;
42    private double height;
43    
44    public Triangle(double base, double height) {
45        this.base = base;
46        this.height = height;
47    }
48    
49    @Override
50    public double calculateArea() {
51        return 0.5 * base * height;
52    }
53}
54
55public class AreaCalculator {
56    // This method NEVER needs to change, no matter how many shapes we add
57    public double calculateTotalArea(Shape[] shapes) {
58        double total = 0;
59        for (Shape shape : shapes) {
60            total += shape.calculateArea();  // Polymorphism in action!
61        }
62        return total;
63    }
64}

See how we can add new shapes without touching the AreaCalculator? That’s the Open/Closed Principle in action.

3. Liskov Substitution Principle (LSP)

The Rule: Objects of a superclass should be replaceable with objects of a subclass without breaking the application.

This is the one that trips up a lot of beginners. It’s saying that if you have a Bird class, and you create a Penguin class that extends Bird, you should be able to use a Penguin anywhere you use a Bird without things breaking.

Here’s a classic example of violating LSP:

 1// BAD: Violates LSP - not all birds can fly!
 2public class Bird {
 3    public void fly() {
 4        System.out.println("I'm flying!");
 5    }
 6}
 7
 8public class Sparrow extends Bird {
 9    // Sparrows can fly, so this works fine
10    @Override
11    public void fly() {
12        System.out.println("Sparrow is flying high!");
13    }
14}
15
16public class Penguin extends Bird {
17    // PROBLEM: Penguins can't fly!
18    @Override
19    public void fly() {
20        throw new UnsupportedOperationException("Penguins can't fly!");
21    }
22}
23
24// This breaks LSP
25public class BirdManager {
26    public void makeBirdsFly(Bird[] birds) {
27        for (Bird bird : birds) {
28            bird.fly();  // This will crash when it hits a Penguin!
29        }
30    }
31}

Here’s the fix - design your hierarchy properly:

 1// GOOD: Proper hierarchy that respects LSP
 2public abstract class Bird {
 3    private String name;
 4    
 5    public Bird(String name) {
 6        this.name = name;
 7    }
 8    
 9    public void eat() {
10        System.out.println(name + " is eating");
11    }
12    
13    public void sleep() {
14        System.out.println(name + " is sleeping");
15    }
16    
17    public String getName() { return name; }
18}
19
20public abstract class FlyingBird extends Bird {
21    public FlyingBird(String name) {
22        super(name);
23    }
24    
25    public abstract void fly();
26}
27
28public abstract class FlightlessBird extends Bird {
29    public FlightlessBird(String name) {
30        super(name);
31    }
32    
33    public abstract void swim();  // Many flightless birds are good swimmers
34}
35
36public class Sparrow extends FlyingBird {
37    public Sparrow(String name) {
38        super(name);
39    }
40    
41    @Override
42    public void fly() {
43        System.out.println(getName() + " the sparrow is flying gracefully!");
44    }
45}
46
47public class Penguin extends FlightlessBird {
48    public Penguin(String name) {
49        super(name);
50    }
51    
52    @Override
53    public void swim() {
54        System.out.println(getName() + " the penguin is swimming like a torpedo!");
55    }
56}
57
58// Now this works perfectly with LSP
59public class BirdManager {
60    public void makeFlyingBirdsFly(FlyingBird[] birds) {
61        for (FlyingBird bird : birds) {
62            bird.fly();  // This will always work!
63        }
64    }
65    
66    public void makeFlightlessBirdsSwim(FlightlessBird[] birds) {
67        for (FlightlessBird bird : birds) {
68            bird.swim();  // This will always work too!
69        }
70    }
71}

Now any FlyingBird can be substituted for another FlyingBird without breaking anything.

4. Interface Segregation Principle (ISP)

The Rule: Don’t force clients to depend on interfaces they don’t use.

This is about not creating “fat interfaces” that do too much. Here’s what I mean:

 1// BAD: Fat interface that does too much
 2public interface Worker {
 3    void work();
 4    void eat();
 5    void sleep();
 6    void attendMeeting();
 7    void writeCode();
 8    void designDatabase();
 9    void testSoftware();
10}
11
12// PROBLEM: A janitor shouldn't have to implement writeCode()!
13public class Janitor implements Worker {
14    @Override
15    public void work() {
16        System.out.println("Cleaning the office");
17    }
18    
19    @Override
20    public void eat() {
21        System.out.println("Eating lunch");
22    }
23    
24    @Override
25    public void sleep() {
26        System.out.println("Sleeping at night");
27    }
28    
29    // These don't make sense for a janitor!
30    @Override
31    public void attendMeeting() {
32        throw new UnsupportedOperationException("Janitors don't attend meetings");
33    }
34    
35    @Override
36    public void writeCode() {
37        throw new UnsupportedOperationException("Janitors don't write code");
38    }
39    
40    @Override
41    public void designDatabase() {
42        throw new UnsupportedOperationException("Janitors don't design databases");
43    }
44    
45    @Override
46    public void testSoftware() {
47        throw new UnsupportedOperationException("Janitors don't test software");
48    }
49}

Better approach - small, focused interfaces:

  1// GOOD: Small, focused interfaces
  2public interface BasicWorker {
  3    void work();
  4    void eat();
  5    void sleep();
  6}
  7
  8public interface MeetingAttendee {
  9    void attendMeeting();
 10}
 11
 12public interface Programmer {
 13    void writeCode();
 14    void testSoftware();
 15}
 16
 17public interface DatabaseDesigner {
 18    void designDatabase();
 19}
 20
 21// Now classes only implement what they actually need
 22public class Janitor implements BasicWorker {
 23    @Override
 24    public void work() {
 25        System.out.println("Cleaning the office");
 26    }
 27    
 28    @Override
 29    public void eat() {
 30        System.out.println("Eating lunch");
 31    }
 32    
 33    @Override
 34    public void sleep() {
 35        System.out.println("Sleeping at night");
 36    }
 37}
 38
 39public class SoftwareDeveloper implements BasicWorker, MeetingAttendee, Programmer {
 40    @Override
 41    public void work() {
 42        System.out.println("Developing software");
 43    }
 44    
 45    @Override
 46    public void eat() {
 47        System.out.println("Eating at desk while debugging");
 48    }
 49    
 50    @Override
 51    public void sleep() {
 52        System.out.println("Finally getting some sleep");
 53    }
 54    
 55    @Override
 56    public void attendMeeting() {
 57        System.out.println("Attending daily standup");
 58    }
 59    
 60    @Override
 61    public void writeCode() {
 62        System.out.println("Writing clean, maintainable code");
 63    }
 64    
 65    @Override
 66    public void testSoftware() {
 67        System.out.println("Writing unit tests");
 68    }
 69}
 70
 71public class FullStackDeveloper implements BasicWorker, MeetingAttendee, 
 72                                          Programmer, DatabaseDesigner {
 73    @Override
 74    public void work() {
 75        System.out.println("Working on full-stack development");
 76    }
 77    
 78    @Override
 79    public void eat() {
 80        System.out.println("Grabbing coffee and snacks");
 81    }
 82    
 83    @Override
 84    public void sleep() {
 85        System.out.println("Dreaming in code");
 86    }
 87    
 88    @Override
 89    public void attendMeeting() {
 90        System.out.println("Attending architecture meetings");
 91    }
 92    
 93    @Override
 94    public void writeCode() {
 95        System.out.println("Writing both frontend and backend code");
 96    }
 97    
 98    @Override
 99    public void testSoftware() {
100        System.out.println("Running integration tests");
101    }
102    
103    @Override
104    public void designDatabase() {
105        System.out.println("Designing efficient database schemas");
106    }
107}

Much cleaner! Each class only implements the interfaces it actually needs.

5. Dependency Inversion Principle (DIP)

The Rule: Depend on abstractions, not concretions.

This is huge for writing testable, flexible code. Instead of creating dependencies inside your classes, pass them in. Here’s the wrong way:

 1// BAD: Hard dependencies make code inflexible
 2public class EmailService {
 3    public void sendEmail(String message) {
 4        System.out.println("Sending email: " + message);
 5        // Email sending logic here
 6    }
 7}
 8
 9public class NotificationService {
10    private EmailService emailService;  // Hard dependency!
11    
12    public NotificationService() {
13        this.emailService = new EmailService();  // Tightly coupled!
14    }
15    
16    public void sendNotification(String message) {
17        // What if we want to send SMS instead? We're stuck with email!
18        emailService.sendEmail(message);
19    }
20}

Better approach - depend on abstractions:

 1// GOOD: Depend on abstractions
 2public interface NotificationSender {
 3    void sendNotification(String message);
 4}
 5
 6public class EmailService implements NotificationSender {
 7    @Override
 8    public void sendNotification(String message) {
 9        System.out.println("Sending email: " + message);
10        // Email sending logic here
11    }
12}
13
14public class SMSService implements NotificationSender {
15    @Override
16    public void sendNotification(String message) {
17        System.out.println("Sending SMS: " + message);
18        // SMS sending logic here
19    }
20}
21
22public class PushNotificationService implements NotificationSender {
23    @Override
24    public void sendNotification(String message) {
25        System.out.println("Sending push notification: " + message);
26        // Push notification logic here
27    }
28}
29
30public class NotificationService {
31    private NotificationSender notificationSender;  // Depends on abstraction!
32    
33    // Dependency is injected, not created internally
34    public NotificationService(NotificationSender notificationSender) {
35        this.notificationSender = notificationSender;
36    }
37    
38    public void sendNotification(String message) {
39        // Works with ANY implementation of NotificationSender
40        notificationSender.sendNotification(message);
41    }
42}
43
44// Usage - now we can easily switch notification methods
45public class NotificationDemo {
46    public static void main(String[] args) {
47        // Want email notifications?
48        NotificationService emailNotifier = 
49            new NotificationService(new EmailService());
50        emailNotifier.sendNotification("Your order has shipped!");
51        
52        // Want SMS notifications?
53        NotificationService smsNotifier = 
54            new NotificationService(new SMSService());
55        smsNotifier.sendNotification("Your order has shipped!");
56        
57        // Want push notifications?
58        NotificationService pushNotifier = 
59            new NotificationService(new PushNotificationService());
60        pushNotifier.sendNotification("Your order has shipped!");
61    }
62}

This is incredibly powerful. You can now easily test your NotificationService by creating a mock NotificationSender, and you can switch notification methods without changing any code.

Putting It All Together

Let’s see how these principles work together in a real example. Here’s a simple order processing system:

  1// Following SRP - each class has one responsibility
  2public class Order {
  3    private String orderId;
  4    private List<String> items;
  5    private double totalAmount;
  6    private String status;
  7    
  8    public Order(String orderId, List<String> items, double totalAmount) {
  9        this.orderId = orderId;
 10        this.items = items;
 11        this.totalAmount = totalAmount;
 12        this.status = "PENDING";
 13    }
 14    
 15    // Only order data management
 16    public String getOrderId() { return orderId; }
 17    public List<String> getItems() { return items; }
 18    public double getTotalAmount() { return totalAmount; }
 19    public String getStatus() { return status; }
 20    public void setStatus(String status) { this.status = status; }
 21}
 22
 23// Following ISP - small, focused interfaces
 24public interface PaymentProcessor {
 25    boolean processPayment(double amount);
 26}
 27
 28public interface NotificationSender {
 29    void sendNotification(String message);
 30}
 31
 32public interface OrderRepository {
 33    void saveOrder(Order order);
 34    Order findOrder(String orderId);
 35}
 36
 37// Following DIP - depend on abstractions
 38public class OrderService {
 39    private PaymentProcessor paymentProcessor;
 40    private NotificationSender notificationSender;
 41    private OrderRepository orderRepository;
 42    
 43    // Dependencies injected, not created internally
 44    public OrderService(PaymentProcessor paymentProcessor,
 45                       NotificationSender notificationSender,
 46                       OrderRepository orderRepository) {
 47        this.paymentProcessor = paymentProcessor;
 48        this.notificationSender = notificationSender;
 49        this.orderRepository = orderRepository;
 50    }
 51    
 52    public void processOrder(Order order) {
 53        // Save order
 54        orderRepository.saveOrder(order);
 55        
 56        // Process payment
 57        if (paymentProcessor.processPayment(order.getTotalAmount())) {
 58            order.setStatus("PAID");
 59            notificationSender.sendNotification(
 60                "Order " + order.getOrderId() + " has been processed successfully!");
 61        } else {
 62            order.setStatus("PAYMENT_FAILED");
 63            notificationSender.sendNotification(
 64                "Payment failed for order " + order.getOrderId());
 65        }
 66        
 67        // Update order status
 68        orderRepository.saveOrder(order);
 69    }
 70}
 71
 72// Following OCP and LSP - can easily add new implementations
 73public class CreditCardProcessor implements PaymentProcessor {
 74    @Override
 75    public boolean processPayment(double amount) {
 76        System.out.println("Processing credit card payment of $" + amount);
 77        return true;  // Simulate successful payment
 78    }
 79}
 80
 81public class PayPalProcessor implements PaymentProcessor {
 82    @Override
 83    public boolean processPayment(double amount) {
 84        System.out.println("Processing PayPal payment of $" + amount);
 85        return true;  // Simulate successful payment
 86    }
 87}
 88
 89public class DatabaseOrderRepository implements OrderRepository {
 90    @Override
 91    public void saveOrder(Order order) {
 92        System.out.println("Saving order " + order.getOrderId() + " to database");
 93    }
 94    
 95    @Override
 96    public Order findOrder(String orderId) {
 97        System.out.println("Finding order " + orderId + " in database");
 98        return null;  // Simplified for demo
 99    }
100}
101
102public class EmailNotificationService implements NotificationSender {
103    @Override
104    public void sendNotification(String message) {
105        System.out.println("Email: " + message);
106    }
107}
108
109// Usage
110public class OrderProcessingDemo {
111    public static void main(String[] args) {
112        // Create dependencies
113        PaymentProcessor paymentProcessor = new CreditCardProcessor();
114        NotificationSender notificationSender = new EmailNotificationService();
115        OrderRepository orderRepository = new DatabaseOrderRepository();
116        
117        // Create service with injected dependencies
118        OrderService orderService = new OrderService(
119            paymentProcessor, notificationSender, orderRepository);
120        
121        // Process an order
122        List<String> items = Arrays.asList("Laptop", "Mouse", "Keyboard");
123        Order order = new Order("ORDER-001", items, 1299.99);
124        
125        orderService.processOrder(order);
126    }
127}

Look at how clean this is! Each class has one job, we can easily add new payment processors or notification methods, and everything is testable because dependencies are injected.

Why This Matters for Your Career

Here’s the real talk - knowing these principles will set you apart from other developers. When you’re in a code review and you suggest splitting a class because it violates SRP, people notice. When you design a system that’s easy to extend because you followed OCP, your team lead remembers.

I’ve seen developers get promoted not because they could write the most clever code, but because they could write code that the whole team could understand and maintain. These principles are your path to writing that kind of code.

Start small. Pick one principle and focus on it for a week. Look at your existing code and see where you might be violating it. Then gradually incorporate the others.

Trust me, six months from now, when you’re looking at code you wrote following these principles, you’ll thank me. And when your colleague needs to modify your code and actually understands it immediately? That’s when you know you’ve made it.

Practice Exercises

Want to really master these principles? Try these exercises:

  1. SRP Challenge: Take a class from your current project that’s doing multiple things. Split it up following SRP.

  2. OCP Challenge: Create a simple calculator that can handle different operations (add, subtract, multiply, divide). Design it so you can add new operations without modifying existing code.

  3. LSP Challenge: Design a hierarchy of geometric shapes. Make sure any subclass can be used wherever the parent class is expected.

  4. ISP Challenge: Design interfaces for different types of media players (audio player, video player, etc.). Make sure classes only implement what they actually use.

  5. DIP Challenge: Create a simple blog application where you can switch between different storage methods (file system, database, cloud) without changing the main blog logic.

These aren’t just academic exercises - they’re the building blocks of professional software development. Master them, and you’ll write code that your future self (and your teammates) will love.


← Previous: Java Fundamentals Next: OOP Concepts →