Solid principles with examples

Satheesh Guduri
5 min readSep 28, 2024

--

Why do we follow solid principles when developing software? The answer is simple: to maintain the software, easy to add new functionality, easy to understand, easy to fix the bugs, and ensure stability.

Single Responsibility:

This principle is very simple. The name indicates that one entity should have one responsibility and should not handle multiple tasks.

For example, consider a login screen. This screen is responsible for taking user inputs such as username and password, and after clicking the login button, it will show success or failure messages. However, if we write logic to call the API inside the button click, it violates the single responsibility principle.

Open/Closed:

A class should be closed to change but open to extensions, every article is having this sentence, but not understanding exactly what it is.

Will understand with simple example. We have different button classes (Button, TextButton, and ElevatedButton) that implement a common interface (OnClickListener). This allows you to introduce new types of buttons without modifying the existing codebase. Each button class can provide its own implementation of the onClick method, while the rest of your code can work with the OnClickListener interface.

public interface OnClickListener {
void onClick(android.view.View view);
}

We can create the buttons like this below.

public class Button implements OnClickListener {
@Override
public void onClick(android.view.View view) {
System.out.println("Button clicked");
}
}

public class TextButton implements OnClickListener {
@Override
public void onClick(android.view.View view) {
System.out.println("TextButton clicked");
}
}

public class ElevatedButton implements OnClickListener {
@Override
public void onClick(android.view.View view) {
System.out.println("ElevatedButton clicked");
}
}

We can use them like below

public class MainActivity {
public static void main(String[] args) {
OnClickListener button = new Button();
OnClickListener textButton = new TextButton();
OnClickListener elevatedButton = new ElevatedButton();

android.view.View view = new android.view.View();

button.onClick(view); // Output: Button clicked
textButton.onClick(view); // Output: TextButton clicked
elevatedButton.onClick(view); // Output: ElevatedButton clicked
}
}

When you want to use any button in your application, you don’t need to know the specifics of how each button handles clicks internally. You only need to interact with them through the OnClickListener interface. This decouples the button usage from their internal implementations, adhering to the Open/Closed Principle.

Liskov Substitution :

Suppose you are creating new activity as MyActivity. This is user defined activity. When you run the app it will executes its life cycles methods, how the system executes your activity life cycle methods, how it knows.

Let’s understand with example.

Base Class (Supertype):

The system provides an Activity class with lifecycle methods like onCreate() and onResume().

User-Defined Subclasses:

You create your own activities, such as LoginActivity and MyActivity, by extending the Activity class.

Method Using the Base Class:

You have a method runActivity(Activity a) that takes an Activity object as a parameter. The system calls the lifecycle methods on this Activity object.

Code Example

Base Class (Supertype)

public class Activity {
public void onCreate() {
System.out.println("Activity onCreate");
}

public void onResume() {
System.out.println("Activity onResume");
}
}

Subclass (Subtype)

public class MyActivity extends Activity {
@Override
public void onCreate() {
System.out.println("MyActivity onCreate");
}

@Override
public void onResume() {
System.out.println("MyActivity onResume");
}
}

Main Method

public class Main {
public static void main(String[] args) {
Activity myActivity = new MyActivity();
runActivity(myActivity);
}

public static void runActivity(Activity a) {
a.onCreate();
a.onResume();
}
}

An instance of MyActivity can replace an instance of Activity without any issues. This demonstrates that MyActivity can be used wherever Activity is expected, adhering to the Liskov Substitution Principle.

Principle: Lower classes (subclasses) should be able to replace upper classes (superclasses) without causing problems. Subclasses can add new features but must not change how the superclass works.

Interface Segregation :

A class should only use the interfaces that it really needs. Instead of using a big interface with lots of methods that the class won’t use, it’s better to use smaller, specific interfaces that contain only the methods necessary for the class. This helps the class avoid using methods it doesn’t need.

Let’s understand with example.

public static interface OnKeyListener {
boolean onKey(android.content.DialogInterface dialogInterface, int i, android.view.KeyEvent keyEvent);
void onDismiss(android.content.DialogInterface dialogInterface);
void onClick(android.content.DialogInterface dialogInterface, int i);
}

Here, we have three methods and we are going to create a CustomButton class by implementing above interface.

public class CustomButton implements OnClickListener {
@Override
public void onClick(android.content.DialogInterface dialogInterface, int i) {
System.out.println("Button clicked");
}
}

With the earlier example(2nd principle) we need only onClick method for the CustomButton class, but if we use OnKeyListener interface then we need to implement all the three methods, which are useless. So, we have to separate them like below.

public static interface OnKeyListener {
boolean onKey(android.content.DialogInterface dialogInterface, int i, android.view.KeyEvent keyEvent);
}

public static interface OnDismissListener {
void onDismiss(android.content.DialogInterface dialogInterface);
}

public static interface OnClickListener {
void onClick(android.content.DialogInterface dialogInterface, int i);
}

Now, we can use like below.

public class Dialog implements OnDismissListener, OnKeyListener {
@Override
public void onDismiss(android.content.DialogInterface dialogInterface) {
System.out.println("Dialog dismissed");
}

@Override
public boolean onKey(android.content.DialogInterface dialogInterface, int i, android.view.KeyEvent keyEvent) {
System.out.println("Key pressed");
return true;
}
}

Dependency Inversion :

If we combine the second and third rules, it forms this principle. First, let’s understand the definition.

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces or abstract classes). Additionally, abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

Let’s understand with simple example.

Let’s illustrate DIP with an example of a simple notification system where we can send notifications via different channels (e.g., email and SMS).

Without DIP (Violation)

In this case, the NotificationService directly depends on the concrete implementations EmailService and SMSService.

public class EmailService {
public void sendEmail(String message) {
System.out.println("Email sent: " + message);
}
}
public class SMSService {
public void sendSMS(String message) {
System.out.println("SMS sent: " + message);
}
}
public class NotificationService {
private EmailService emailService = new EmailService();
private SMSService smsService = new SMSService();

public void sendNotification(String message) {
emailService.sendEmail(message);
smsService.sendSMS(message);
}
}
public class Main {
public static void main(String[] args) {
NotificationService notificationService = new NotificationService();
notificationService.sendNotification("Hello, World!");
}
}

With DIP (Adheres to DIP)

In this case, the NotificationService depends on an abstraction (MessageService), and the concrete implementations (EmailService and SMSService) also depend on this abstraction.

public interface MessageService {
void sendMessage(String message);
}
public class EmailService implements MessageService {
@Override
public void sendMessage(String message) {
System.out.println("Email sent: " + message);
}
}
public class SMSService implements MessageService {
@Override
public void sendMessage(String message) {
System.out.println("SMS sent: " + message);
}
}
public class NotificationService {
private MessageService messageService;

public NotificationService(MessageService messageService) {
this.messageService = messageService;
}

public void sendNotification(String message) {
messageService.sendMessage(message);
}
}
public class Main {
public static void main(String[] args) {
MessageService emailService = new EmailService();
NotificationService emailNotification = new NotificationService(emailService);
emailNotification.sendNotification("Hello via Email!");

MessageService smsService = new SMSService();
NotificationService smsNotification = new NotificationService(smsService);
smsNotification.sendNotification("Hello via SMS!");
}
}

Explanation

Abstraction (NotificationSender Interface):

Defines a common method send that both EmailService and SMSService implement.

Low-level Modules (EmailService and SMSService):

Implement the NotificationSender interface.

High-level Module (NotificationService):

Depends on the NotificationSender interface rather than concrete implementations.

Receives the specific implementation through dependency injection (constructor).

By adhering to the Dependency Inversion Principle, the high-level NotificationService is decoupled from the specific low-level implementations, making the system more flexible and easier to extend. You can add new notification services (e.g., PushNotificationService) without modifying the NotificationService class.

--

--

Satheesh Guduri
Satheesh Guduri

Written by Satheesh Guduri

Mobile App Developer and Trainer

No responses yet