Introduction to Object-Oriented Design Principles
Object-oriented design principles are fundamental concepts that guide the design and development of software systems. These principles help organize and structure code, making it more modular, maintainable, and extensible.
As a senior engineer with intermediate knowledge of Java and Python, you may already be familiar with the basics of object-oriented programming. Object-oriented design principles build upon these foundations and provide guidelines to help you design robust and flexible software.
In this lesson, we will explore the following object-oriented design principles:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
By understanding and applying these principles, you will be able to create well-organized, reusable, and maintainable code.
Let's get started by familiarizing ourselves with each of these principles in more detail.
xxxxxxxxxx
class Main {
public static void main(String[] args) {
System.out.println("Welcome to Object-Oriented Design Principles!");
}
}
Build your intuition. Click the correct answer from the options.
What is the purpose of object-oriented design principles?
Click the option that best answers the question.
- To make code more readable
- To make code run faster
- To make code more secure
- To make code more modular and maintainable
Single Responsibility Principle (SRP)
The Single Responsibility Principle (SRP) is one of the five object-oriented design principles that form the SOLID principles. It states that a class should have only one reason to change. In other words, a class should have only one responsibility.
Applying SRP to your code promotes modularity and maintainability. By ensuring that each class has a single responsibility, you can isolate changes related to that responsibility and minimize the impact on other parts of the codebase.
For example, let's consider a Car
class. According to SRP, the Car
class should be responsible for only one aspect of a car, such as storing and setting the car's model and color:
1class Car {
2 private String model;
3 private String color;
4
5 public void setModel(String model) {
6 this.model = model;
7 }
8
9 public void setColor(String color) {
10 this.color = color;
11 }
12}
13
14public class Main {
15 public static void main(String[] args) {
16 Car car = new Car();
17 car.setModel("Toyota Camry");
18 car.setColor("Blue");
19 }
20}
In this example, the Car
class has a single responsibility of managing the model and color of a car. If we need to make changes to how the car's model or color is handled, we only need to modify the Car
class.
By following the Single Responsibility Principle, we can make our code easier to understand, test, and maintain.
xxxxxxxxxx
class Car {
private String model;
private String color;
​
public void setModel(String model) {
this.model = model;
}
​
public void setColor(String color) {
this.color = color;
}
}
​
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.setModel("Toyota Camry");
car.setColor("Blue");
}
}
Build your intuition. Is this statement true or false?
The Single Responsibility Principle (SRP) states that a class should have multiple responsibilities.
Press true if you believe the statement is correct, or false otherwise.
Open-Closed Principle (OCP)
The Open-Closed Principle (OCP) is one of the five object-oriented design principles that form the SOLID principles. It states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
This means that once a software entity is implemented and tested, it should not be modified to add new functionality. Instead, new functionality should be added by extending or subclassing the existing entity.
The OCP promotes extensibility and avoids the need to modify existing code, reducing the risk of introducing bugs. It encourages the use of abstraction and inheritance to allow for easy addition of new features.
For example, let's consider a Shape
class that has a method to calculate the area:
1abstract class Shape {
2 abstract double calculateArea();
3}
4
5class Rectangle extends Shape {
6 private double width;
7 private double height;
8
9 public Rectangle(double width, double height) {
10 this.width = width;
11 this.height = height;
12 }
13
14 @Override
15 double calculateArea() {
16 return width * height;
17 }
18}
19
20class Circle extends Shape {
21 private double radius;
22
23 public Circle(double radius) {
24 this.radius = radius;
25 }
26
27 @Override
28 double calculateArea() {
29 return Math.PI * radius * radius;
30 }
31}
32
33public class Main {
34 public static void main(String[] args) {
35 // Code to calculate the area of different shapes
36 Shape rectangle = new Rectangle(4, 5);
37 Shape circle = new Circle(3);
38
39 System.out.println("Area of rectangle: " + rectangle.calculateArea());
40 System.out.println("Area of circle: " + circle.calculateArea());
41 }
42}
In this example, the Shape
class is open for extension as new shapes can be added by creating new subclasses such as Rectangle
and Circle
. The existing code does not need to be modified to accommodate new shapes.
By adhering to the Open-Closed Principle, we can design code that is easily maintainable, reusable, and scalable.
xxxxxxxxxx
class Main {
public static void main(String[] args) {
// Replace with your Java logic here
System.out.println("Open-Closed Principle");
}
}
Are you sure you're getting this? Fill in the missing part by typing it in.
The Open-Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for ____ but closed for ____. This means that once a software entity is implemented and tested, it should not be ____ to add new functionality. Instead, new functionality should be added by extending or ____ the existing entity.
The OCP promotes extensibility and avoids the need to modify ____ code, reducing the risk of introducing bugs. It encourages the use of abstraction and ____ to allow for easy addition of new features.
Write the missing line below.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) is one of the five object-oriented design principles that form SOLID. It is named after Barbara Liskov, who defined it in 1987.
The principle states that if a type S
is a subtype of type T
, then objects of type T
can be replaced with objects of type S
without affecting the correctness of the program.
In simpler terms, this principle ensures that subtypes can be used interchangeably with their base types.
In programming languages that support inheritance, the LSP helps to establish and maintain a strong and predictable relationship between classes and their subclasses. It allows for polymorphism, where a variable of a base type can refer to an object of any subtype.
For example, consider a scenario where we have an Animal
class and a Dog
class that extends the Animal
class. According to the LSP, we should be able to use an instance of Dog
wherever an instance of Animal
is expected. Let's see an example in Java:
1public class Main {
2 public static void main(String[] args) {
3 Animal animal = new Animal();
4 Dog dog = new Dog();
5 animal.makeSound(); // Output: "Animal is making a sound"
6 dog.makeSound(); // Output: "Dog is barking"
7 animal = new Dog();
8 animal.makeSound(); // Output: "Dog is barking"
9 }
10}
11
12class Animal {
13 public void makeSound() {
14 System.out.println("Animal is making a sound");
15 }
16}
17
18class Dog extends Animal {
19 public void makeSound() {
20 System.out.println("Dog is barking");
21 }
22}
In this example, we have an Animal
class with a makeSound
method that prints "Animal is making a sound". The Dog
class extends the Animal
class and overrides the makeSound
method to print "Dog is barking".
According to the LSP, when we create an instance of Dog
and assign it to a variable of type Animal
, calling the makeSound
method on that variable should still give us the expected behavior. In this case, calling animal.makeSound()
would print "Dog is barking", as expected.
By following the Liskov Substitution Principle, we can ensure that our code is flexible, maintainable, and less prone to bugs. It allows us to write code that can work with multiple types and be easily extended in the future.
xxxxxxxxxx
public class Main {
public static void main(String[] args) {
Animal animal = new Animal();
Dog dog = new Dog();
animal.makeSound(); // Output: "Animal is making a sound"
dog.makeSound(); // Output: "Dog is barking"
animal = new Dog();
animal.makeSound(); // Output: "Dog is barking"
}
}
​
class Animal {
public void makeSound() {
System.out.println("Animal is making a sound");
}
}
​
class Dog extends Animal {
public void makeSound() {
System.out.println("Dog is barking");
}
}
Are you sure you're getting this? Click the correct answer from the options.
Which of the following statements best describes the Liskov Substitution Principle (LSP)?
Click the option that best answers the question.
Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) is one of the five object-oriented design principles that form SOLID. It emphasizes that client-specific interfaces are better than one general-purpose interface.
The ISP states that clients should not be forced to depend on interfaces they do not use. Instead, interfaces should be fine-grained and specific to the needs of the client.
To understand the need for the ISP, let's consider an example where we have a Printer
interface and two classes implementing it: SimplePrinter
and AdvancedPrinter
:
1interface Printer {
2 void print(Document document);
3}
4
5class SimplePrinter implements Printer {
6 public void print(Document document) {
7 System.out.println("Printing document " + document.getTitle());
8 }
9}
10
11class AdvancedPrinter implements Printer {
12 public void print(Document document) {
13 System.out.println("Printing document " + document.getTitle() + " with advanced options");
14 }
15}
In this example, the Printer
interface has a single method print
, which takes a Document
object as a parameter. The SimplePrinter
class provides a basic implementation of the printer, while the AdvancedPrinter
class provides a more advanced implementation.
According to the ISP, it is better to have client-specific interfaces for different printing needs, rather than a single general-purpose interface. This allows clients to depend only on the interfaces they need, reducing unnecessary dependencies and making the code easier to understand and maintain.
By following the ISP, we can avoid situations where clients are forced to implement methods they do not need or depend on unnecessary functionality. This promotes loose coupling and allows for better modularization of code.
In the example above, instead of having a single Printer
interface, we could have separate interfaces SimplePrinter
and AdvancedPrinter
, each with their own methods specific to their functionality. This would allow clients to depend only on the interface they need, improving code readability and reducing the impact of changes when new functionalities are added or existing ones are modified.
By applying the ISP, we can design interfaces that are cohesive, minimal, and focused on the needs of the client, resulting in flexible and maintainable code.
xxxxxxxxxx
}
interface Printer {
void print(Document document);
}
​
class SimplePrinter implements Printer {
public void print(Document document) {
System.out.println("Printing document " + document.getTitle());
}
}
​
class AdvancedPrinter implements Printer {
public void print(Document document) {
System.out.println("Printing document " + document.getTitle() + " with advanced options");
}
}
​
class Document {
private String title;
​
public Document(String title) {
this.title = title;
}
​
public String getTitle() {
return title;
}
}
​
public class Main {
Let's test your knowledge. Click the correct answer from the options.
According to the Interface Segregation Principle (ISP), clients should depend on:
a) General-purpose interfaces.
b) Client-specific interfaces.
c) Optional interfaces.
d) Inherited interfaces.
Click the option that best answers the question.
- a
- b
- c
- d
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) is one of the five object-oriented design principles that form SOLID. It emphasizes that high-level modules should not depend on low-level modules; both should depend on abstractions.
The DIP states that the inversion of control should be applied to achieve loose coupling and improved flexibility. Instead of high-level modules depending on low-level modules directly, they should both depend on abstractions, typically defined by interfaces or abstract classes.
To understand the need for the DIP, let's consider an example where we have a UserController
class, which depends on a UserRepository
class to retrieve user data from a database:
1public class UserController {
2 private UserRepository userRepository;
3
4 public UserController() {
5 this.userRepository = new UserRepository();
6 }
7
8 public void getUser(int userId) {
9 User user = userRepository.getUser(userId);
10 // process user data
11 }
12}
13
14public class UserRepository {
15 public User getUser(int userId) {
16 // retrieve user data from database
17 return user;
18 }
19}
In this example, the UserController
directly depends on the UserRepository
class, creating a strong coupling between the two. This makes the UserController
less flexible and harder to test, as it becomes tightly coupled to the implementation details of the UserRepository
.
By applying the DIP, we can achieve loose coupling and improved flexibility. Instead of the UserController
depending directly on the UserRepository
, we can introduce an abstraction, such as an IUserRepository
interface, that both the UserController
and UserRepository
depend on:
1public interface IUserRepository {
2 User getUser(int userId);
3}
4
5public class UserController {
6 private IUserRepository userRepository;
7
8 public UserController(IUserRepository userRepository) {
9 this.userRepository = userRepository;
10 }
11
12 public void getUser(int userId) {
13 User user = userRepository.getUser(userId);
14 // process user data
15 }
16}
17
18public class UserRepository implements IUserRepository {
19 public User getUser(int userId) {
20 // retrieve user data from database
21 return user;
22 }
23}
In this refactored example, the UserController
now depends on the IUserRepository
interface instead of the UserRepository
class directly. This allows for better separation of concerns and flexibility, as we can easily swap out different implementations of the IUserRepository
interface without affecting the UserController
.
By following the DIP, we can achieve loose coupling, inversion of dependencies, and improved flexibility in our codebase. This leads to code that is easier to understand, maintain, and test.
xxxxxxxxxx
class Main {
public static void main(String[] args) {
// replace with your Java logic here
}
}
Build your intuition. Is this statement true or false?
The Dependency Inversion Principle (DIP) promotes tight coupling between high-level and low-level modules.
Press true if you believe the statement is correct, or false otherwise.
Applying Design Principles in Practice
Now that we have learned about the five object-oriented design principles, let's explore how we can apply them in practice. Object-oriented design principles provide guidelines and best practices for designing and organizing our code, ensuring that it is flexible, maintainable, and extensible.
Here are some examples of how we can apply the design principles in real-world scenarios:
Single Responsibility Principle (SRP): When designing classes, make sure each class has a single responsibility or purpose. For example, if we have a
User
class, it should only be responsible for managing user data and not for performing unrelated tasks such as sending emails.Open-Closed Principle (OCP): Design our code to be open for extension but closed for modification. This means that we should be able to add new functionality or behavior to our code without modifying existing code. For example, if we have a
Shape
class with acalculateArea()
method, we can extend this class to create new shapes without modifying the existingcalculateArea()
method.Liskov Substitution Principle (LSP): Ensure that subtypes can be used interchangeably with their base types. This principle helps maintain compatibility between different implementations of an interface or superclass. For example, if we have a
Vehicle
interface with adrive()
method, any class implementing this interface should be able to be used in place of theVehicle
interface without breaking the code.Interface Segregation Principle (ISP): Design fine-grained interfaces that are specific to the needs of the clients or classes that use them. Avoid creating large, monolithic interfaces that force classes to implement unnecessary methods. For example, if we have a
Printable
interface, we can create specific sub-interfaces likeInkjetPrintable
andLaserPrintable
that define methods specific to inkjet and laser printers.Dependency Inversion Principle (DIP): Depend on abstractions and not on concrete implementations. This principle promotes loose coupling and allows us to easily switch implementations without affecting the higher-level modules. For example, if we have a
PaymentProcessor
class, it should depend on anIPaymentService
interface instead of a specific payment service implementation.
By applying these design principles in our code, we can create systems that are more maintainable, extensible, and easily adaptable to changes.
Let's practice implementing these principles in code. Here's an example that demonstrates the Single Responsibility Principle (SRP):
1public class User {
2 private String name;
3 private int age;
4
5 public User(String name, int age) {
6 this.name = name;
7 this.age = age;
8 }
9
10 // Getters and setters
11
12 public String getName() {
13 return name;
14 }
15
16 public int getAge() {
17 return age;
18 }
19
20 public void setName(String name) {
21 this.name = name;
22 }
23
24 public void setAge(int age) {
25 this.age = age;
26 }
27
28 // Other user-related methods
29
30 public void greet() {
31 System.out.println("Hello, my name is " + name + " and I am " + age + " years old.");
32 }
33
34 public void sendEmail(String message) {
35 // Code to send email
36 }
37}
In this example, we have a User
class that has a single responsibility of managing user data. The class has methods to get and set the name and age, as well as other user-related methods such as greet()
. The class does not have any email sending related logic, as that would violate the Single Responsibility Principle (SRP).
Take some time to explore more examples and best practices for applying object-oriented design principles. Understanding and applying these principles will greatly improve the quality and maintainability of your code.
xxxxxxxxxx
class Main {
public static void main(String[] args) {
// replace with your Java logic here
// Example code snippet relevant to the content
String name = "John";
int age = 25;
System.out.println("My name is " + name + " and I am " + age + " years old.");
}
}
Build your intuition. Fill in the missing part by typing it in.
The Open-Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be ___ for extension but __ for modification.
Write the missing line below.
Generating complete for this lesson!