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:
SRP Challenge: Take a class from your current project that’s doing multiple things. Split it up following SRP.
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.
LSP Challenge: Design a hierarchy of geometric shapes. Make sure any subclass can be used wherever the parent class is expected.
ISP Challenge: Design interfaces for different types of media players (audio player, video player, etc.). Make sure classes only implement what they actually use.
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.