Java Solid Principles
By gobrain
Jun 14th, 2024
SOLID Principles is five key principles in object-oriented design (OOD). These principles are meant to create more understandable, flexible, and maintainable software.
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, so that you can easily understand their uses.
Let's first get started with what these principles are.
What are SOLID principles?
SOLID principles were introduced by Robert C. Martin and have become widely adopted in modern software development.
Actually, SOLID 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
As its name suggests, the Single Responsibility Principle (SRP) states 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 prevent classes or functions from becoming too complex and makes them easier to test and maintain.
Now, first let's see an example that does not follow the single responsibility principle 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.