Share


S.O.L.I.D  principles in java with code examples

SOLID Principles in Java With Code Examples


By gobrain

Jan 1st, 2024

Solid Principles is a set of principles for object oriented software development that helps in creating code that is easy to maintain and extend, flexible and robust. But what was the reason for their creation?

Developers require such guidelines because while writing functional code is crucial, developing maintainable and scalable code is of extreme importance due to the iterative nature of the development process and the collaborative nature of software development.

So, in this article, we will cover the SOLID principles in Java and provide real world code examples related to database connection operations for each principle to make them clear. Let's first get started with what these principles are.

What are SOLID principles?

SOLID, introduced by Robert C. Martin, is an acronym that represents:

  • Single Responsibility Principle (SRP)
  • Open-Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

Let's cover them in detail.

Single Responsibility Principle

This principle says that a class should only fulfill one responsibility. According to the SRP, a class should focus on doing one thing well and should not be responsible for unrelated functionality. This helps to prevent the class from becoming too complex and makes it easier to test and maintain.

An Example That Does Not Follow The Single Responsibility Principle In Java

To illustrate this point, we will create a Java class that violates the Single Responsibility Principle and then refactor it to adhere to the principle.

public class Database {

      // read database configuration from file
    public Properties readDatabaseConfigFromFile(String filePath) {
        // code to read the database configuration from file and return it as a Properties object
        return properties;
    }
    
    // save database configuration to file
    public void saveDatabaseConfigToFile(Properties properties, String filePath) {
        // code to save the database configuration to file
    }
    
    // establish database connection
    public Connection connectToDatabase() {
        // code to establish a connection to the database
        return connection;
    }
    
    // close database connection
    public void closeDatabaseConnection(Connection connection) {
        // code to close the connection to the database
    }
}

What is wrong with it? The class responsible for managing database configuration and connection is handling two separate tasks, which can make it difficult to test and maintain. This violates the Single Responsibility Principle, which states that a class should have only one reason to change.

_An Example That Follow The Single Responsibility Principle _

Now, let's fix the class above by separating each task by different classes. The first class would be the Database Configuration class, which would be responsible for handling the tasks related to configuring the database. The second class would be the DatabaseConnection class, which would be responsible for managing the connections to the database.

// DatabaseConfig class responsible for managing the database configuration
public class DatabaseConfig {
    
    // read database configuration from file
    public Properties readDatabaseConfigFromFile(String filePath) {
        // code to read the database configuration from file and return it as a Properties object
        return properties;
    }
    
    // save database configuration to file
    public void saveDatabaseConfigToFile(Properties properties, String filePath) {
        // code to save the database configuration to file
    }
}


// DatabaseConnection class responsible for managing the database connection
public class DatabaseConnection {
    
    // establish database connection
    public Connection connectToDatabase() {
        // code to establish a connection to the database
        return connection;
    }
    
    // close database connection
    public void closeDatabaseConnection(Connection connection) {
        // code to close the connection to the database
    }
}

Open-Closed Principle

This principle says that a class should be extended with new features but it should not be modified. Consider the classes above, when the need arises to add a new database connection to your project or different type configuration methods these classes and its methods must be modified to management connection and confiuration of the database.

So let's fix this:

// DatabaseConfig interface to define the contract for database configuration
public interface DatabaseConfig {
    
    Properties readDatabaseConfigFromFile(String filePath);
    void saveDatabaseConfigToFile(Properties properties, String filePath);
    
}

// FileDatabaseConfig class to implement the DatabaseConfig interface for file-based configuration
public class FileDatabaseConfig implements DatabaseConfig {
    
    public Properties readDatabaseConfigFromFile(String filePath) {
        // code to read the database configuration from file and return it as a Properties object
        return properties;
    }
    
    public void saveDatabaseConfigToFile(Properties properties, String filePath) {
        // code to save the database configuration to file
    }
}

// DatabaseConnection interface to define the contract for database connection
public interface DatabaseConnection {
    
    Connection connectToDatabase();
    void closeDatabaseConnection(Connection connection);
    
}

// JDBCDatabaseConnection class to implement the DatabaseConnection interface for JDBC-based connection
public class JDBCDatabaseConnection implements DatabaseConnection {
    
    public Connection connectToDatabase() {
        // code to establish a connection to the database using JDBC
        return connection;
    }
    
    public void closeDatabaseConnection(Connection connection) {
        // code to close the connection to the database
    }
}

In this example, we introduced two new interfaces: DatabaseConfig and DatabaseConnection, which define the contracts for database configuration and connection respectively. We then created two new classes: FileDatabaseConfig and JDBCDatabaseConnection, which implement these interfaces for file-based configuration and JDBC-based connection respectively.

By using interfaces and abstraction, we have made the DatabaseConfig and DatabaseConnection classes more flexible and extensible. We can now add more implementation classes that adhere to the same contracts, without modifying the existing code. This makes the code more open for extension, while still being closed for modification.

For example, if we wanted to add support for XML-based configuration or a different database driver, we could create new implementation classes that implement the DatabaseConfig and DatabaseConnection interfaces respectively, without modifying the existing FileDatabaseConfig or JDBCDatabaseConnection classes. This ensures that the existing code remains unchanged and the new features can be added without affecting the existing functionality.

Liskov Substitution Principle

This principle says that, behavior of the subclasses should be consistent with the base class. For example, if you have a base class for databases and two subclasses, PSQL and MySQL, these two subclasses can be used anywhere a database is expected.

interface Database {
 void create()
}

public class MySQL implements Database{
  public void create(){
   // Code here
  }
}

public class PSQL implements Database{
  public void create(){
   // Code here
  }
}

In this case, the "Database" interface defines the "create" method, and both MySQL and PSQL classes implement the Database interface, providing their own implementation of the "create" method. This means that an instance of either the MySQL or PSQL class can be used wherever an instance of the Database interface is required, without affecting the correctness of the program.

Interface Segregation Principle

The Interface Segregation Principle states that a class should implement only required interfaces. For example, suppose you have two databases, one of which performs a write operation, the other don't, so it would make sense to allocate the interfaces for each operation.

interface Read {
    List<Map<String, Object>> readData(String query);
}

interface Write {
    void writeData(String query);
}

class PSQL implements Read, Write {
    public List<Map<String, Object>> readData(String query) {
        // Implementation to read data from PSQL
        return null;
    }

    public void writeData(String query) {
        // Implementation to write data to PSQL
    }
}

class MySQL implements Write {
// This database performs only write operations
    public void writeData(String query) {
        // Implementation to write data to MySQL
    }
}

Dependency Inversion Principle

This principle says that, a class should not depend on specific implementations of other classes, but should depend on abstractions.

Consider a scenario with two database classes and a database client class. In this scenario, you can create instances of the databases and the client depends on each database directly. However, by creating a database interface, the client can depend on the databases loosely through polymorphism. This allows for the client to interact with different databases without being tightly coupled to a specific implementation.

interface Database {
    List<Map<String, Object>> executeQuery(String query);
}

class MySQL implements Database {
    public List<Map<String, Object>> executeQuery(String query) {
        // Implementation to execute query on MySQL
        return null;
    }
}

class PSQL implements Database {
    public List<Map<String, Object>> executeQuery(String query) {
        // Implementation to execute query on PSQL
        return null;
    }
}

class DatabaseClient {
    private Database database;

    public DatabaseClient(Database database) {
        this.database = database;
    }

    public List<Map<String, Object>> executeQuery(String query) {
        return this.database.executeQuery(query);
    }
}

Conclusion

In this article, we have provided some code examples related to database management in Java to explain the SOLID principles, serving as a guide for developers to write efficient, maintainable and sustainable code for object-oriented programming.

Thank you for reading.