Best Practice Programming and SOLID Principles

Aushaaf Fadhilah Azzah
7 min readApr 23, 2024

--

Best practice programming encompasses a set of guidelines, principles, and techniques that developers follow to write high-quality, maintainable, and efficient code. One of the cornerstone concepts in best practice programming is the SOLID principles, which are a set of five design principles aimed at improving the architecture and design of object-oriented software. In this article, we’ll delve into the details of best practice programming, focusing specifically on the SOLID principles, and provide detailed examples to illustrate each principle.

Understanding Best Practice Programming

Best practice programming is all about adopting proven techniques and principles that lead to better code quality, easier maintenance, and enhanced scalability. Some key aspects of best practice programming include:

  1. Modularity: Breaking down code into modular components to promote reusability and maintainability.
  2. Clean Code: Writing code that is easy to read, understand, and modify.
  3. Efficiency: Optimizing code for performance and resource utilization.
  4. Testing: Emphasizing the importance of testing to ensure code correctness and reliability.
  5. Documentation: Documenting code to facilitate understanding and collaboration among developers.

Object-Oriented Programming

Object-Oriented Programming (OOP) is a programming paradigm that designs software based on objects resembling real-world entities. Each object has its attributes and methods defined within its class.

In OOP, there are four principles concerning how objects interact with each other:

  1. Encapsulation: Objects hide their internal workings, exposing only necessary attributes and methods to the outside world.
  2. Inheritance: Classes can be derived from existing classes, inheriting their attributes and methods and allowing for customization through overriding or addition.
  3. Polymorphism: Methods can behave differently based on the specific subclass, allowing for flexibility and dynamic behavior.
  4. Abstraction: Focuses on the general characteristics of an object, ignoring implementation details for a clearer understanding.

SOLID Principles

The SOLID principles are a set of guidelines that help software developers create maintainable, scalable, and robust object-oriented code. These principles, introduced by Robert C. Martin (Uncle Bob), are widely regarded as fundamental concepts in object-oriented design. In this section, we’ll delve into each SOLID principle, explain its importance, and provide examples to illustrate how they can be applied in real-world scenarios.

1. Single Responsibility Principle (SRP)

A class should have only one reason to change, or in other words, it should have only one responsibility.

The Single Responsibility Principle states that a class should have only one reason to change, meaning it should have a single responsibility or focus. This principle promotes code that is more modular, easier to understand, and less prone to bugs.

Example

Consider a UserService class that handles user management operations such as registration, authentication, and profile management.

public class UserService {
public void registerUser(User user) {
// Logic for user registration
}

public boolean authenticateUser(String username, String password) {
// Logic for user authentication
return true;
}

public UserProfile getUserProfile(User user) {
// Logic to retrieve user profile
return userProfile;
}
}

Following SRP, we can separate these responsibilities into distinct classes:

public class UserRegistrationService {
public void registerUser(User user) {
// Logic for user registration
}
}
public class UserAuthenticationService {
public boolean authenticateUser(String username, String password) {
// Logic for user authentication
return true;
}
}
public class UserProfileService {
public UserProfile getUserProfile(User user) {
// Logic to retrieve user profile
return userProfile;
}
}

2. Open/Closed Principle (OCP)

Software entities (classes, modules, functions, and so on) should be open for extension but closed for modification.

The Open/Closed Principle advocates for classes to be open for extension but closed for modification. This means that classes should be designed in a way that new functionality can be added through extension without altering existing code.

Example

Suppose we have a Shape class with different subclasses for geometric shapes.

public abstract class Shape {
public abstract double area();
}

public class Rectangle extends Shape {
private double length;
private double width;

public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}

@Override
public double area() {
return length * width;
}
}

We have applied OCP by allowing new shapes to be added without modifying the Shape class:

public class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double area() {
return Math.PI * radius * radius;
}
}

3. Liskov Substitution Principle (LSP)

Any instance of a derived class should be substitutable for an instance of its base class without affecting the correctness of the program.

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. This principle ensures consistency and compatibility in object-oriented designs.

Example

Consider a Bird superclass with Duck and Ostrich subclasses. Both subclasses should be substitutable for Bird without causing unexpected behavior:

class Rectangle {
protected int width;
protected int height;

public void setWidth(int width) {
this.width = width;
}

public void setHeight(int height) {
this.height = height;
}

public int calculateArea() {
return width * height;
}
}

class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}

@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}

public class LiskovSubstitutionDemo {
public static void main(String[] args) {
Rectangle rectangle = new Square();
rectangle.setWidth(5);
rectangle.setHeight(10);
int area = rectangle.calculateArea();
System.out.println("Area: " + area); // 100
}
}

In the LiskovSubstitutionDemo class, we create a Rectangle object but assign it a Square instance. We then set the width and height using the setWidth and setHeight methods, expecting it to behave like a rectangle. However, since Square overrides these methods to maintain equal width and height, the computed area is incorrect, violating the expected behavior of a rectangle.

To adhere to the Liskov Substitution Principle, we need to revise our design. One way to do this is to remove the inheritance relationship between Square and Rectangle and instead create separate classes that represent a square and a rectangle without relying on inheritance.

class Rectangle {
protected int width;
protected int height;

public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}

public int calculateArea() {
return width * height;
}
}

class Square {
private int side;

public Square(int side) {
this.side = side;
}

public int calculateArea() {
return side * side;
}
}

public class LiskovSubstitutionDemo {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(5, 10);
Square square = new Square(5);

int rectangleArea = rectangle.calculateArea();
int squareArea = square.calculateArea();

System.out.println("Rectangle Area: " + rectangleArea); // 50
System.out.println("Square Area: " + squareArea); // 25
}
}

4. Interface Segregation Principle (ISP)

Interfaces should be segregated based on the functionality they provide, rather than having a monolithic interface.

This principle avoids forcing clients to implement interfaces with methods they don’t need.

Example

Consider an Animal interface that includes methods for both flying and swimming. However, not all animals can both fly and swim. This design forces classes like Bird and Fish to implement methods that are not applicable to them, violating the Interface Segregation Principle.

public interface Animal {
void fly();
void swim();
}

public class Bird implements Animal {
@Override
public void fly() {
// Implementation for flying birds
}

@Override
public void swim() {
// Not applicable for birds
}
}

public class Fish implements Animal {
@Override
public void fly() {
// Not applicable for fish
}

@Override
public void swim() {
// Implementation for swimming fish
}
}

We can segregate Animal interface into Flyable and Swimmable based on their characteristics. Now, classes such as Bird only need to implement the Flyable interface, and classes like Fish only need to implement the Swimmable interface. This design ensures that classes implement only the methods they need, avoiding unnecessary dependencies and promoting a cleaner interface structure.

public interface Flyable {
void fly();
}

public interface Swimmable {
void swim();
}

public class Bird implements Flyable {
@Override
public void fly() {
// Implementation for flying birds
}
}

public class Fish implements Swimmable {
@Override
public void swim() {
// Implementation for swimming fish
}
}

5. Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules, but both should depend on abstractions.

Abstractions should not depend on details. Details should depend on abstractions.

The Dependency Inversion Principle emphasizes dependency inversion, where high-level modules should not depend on low-level modules. Instead, both should depend on abstractions, promoting loose coupling and easier maintenance.

Example

Consider a PaymentService class which directly depends on the PayPalGateway concrete implementation. This tight coupling makes it challenging to switch to a different payment gateway implementation without modifying the PaymentService class, violating the Dependency Inversion Principle.

public class PaymentService {
private PayPalGateway payPalGateway;

public PaymentService() {
this.payPalGateway = new PayPalGateway();
}

public void makePayment(double amount) {
// Business logic for making payments
payPalGateway.processPayment(amount);
}
}

After applying the Dependency Inversion Principle, the PaymentService class depends on the PaymentGateway interface instead of a concrete implementation. This abstraction allows us to inject different payment gateway implementations (such as PayPalGateway, StripeGateway, etc.) into PaymentService without changing its code. This decoupling promotes flexibility, maintainability, and easier integration of new payment gateway implementations into the system.

public interface PaymentGateway {
void processPayment(double amount);
}

public class PayPalGateway implements PaymentGateway {
@Override
public void processPayment(double amount) {
// Implementation for processing payment via PayPal
}
}

public class PaymentService {
private PaymentGateway gateway;

public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}

public void makePayment(double amount) {
// Business logic for making payments
gateway.processPayment(amount);
}
}

Conclusion

By understanding and applying the SOLID principles along with other best practice programming concepts, developers can write code that is more maintainable, scalable, and robust. These principles promote clean design, modular architecture, and efficient code reuse, leading to higher productivity and better software quality. Incorporating these principles into your programming practices can significantly enhance the overall development process and the quality of your software products.

--

--