JavaRush /Java Blog /Random EN /SOLID principles that make code cleaner
Paul Soia
Level 26
Kiyv

SOLID principles that make code cleaner

Published in the Random EN group
What is SOLID? Here's how the SOLID acronym stands for: - S: Single Responsibility Principle  . - O: Open-Closed Principle  . - L: Liskov Substitution Principle  . - I: Interface Segregation Principle  . - D: Dependency Inversion Principle  . SOLID principles advise how to design modules, i.e. the building blocks from which the application is built. The purpose of the principles is to design modules that: - promote change - are easily understood - are reusable So, let's look at what it is with examples.
class MainRepository(
    private val auth: FirebaseAuth
) {
    suspend fun loginUser(email: String, password: String) {
        try {
            auth.signInWithEmailAndPassword(email, password)
        } catch (e: Exception) {
            val file = File("errors.txt")
            file.appendText(text = e.message.toString())
        }
    }
}
What do we see in this example? FirebaseAuth is passed in the constructor, we are trying to log in with it. If we receive an error in response, then we write the message to a file. Now let's see what's wrong with this code. 1. S: Single Responsibility Principle  : A class (or function/method) should only be responsible for one thing. If a class is responsible for solving several tasks, its subsystems that implement the solution of these tasks turn out to be connected with each other. Changes in one such subsystem lead to changes in another. In our example, the loginUser function is responsible for two things - logging in and writing the error to a file. In order for the responsibility to be one, it is necessary to take out the logic of recording an error in a separate class.
class FileLogger{
    fun logError(error: String) {
        val file = File("errors.txt")
        file.appendText(text = error)
    }
}
class MainRepository(
    private val auth: FirebaseAuth,
    private val fileLogger: FileLogger,
) {
    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        } catch (e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }
}
The error recording logic is moved to a separate class, and now the loginUser method has only one responsibility - authorization. If the error logging logic needs to be changed, then this will be done in the FileLogger class, and not in the loginUser 2 function. O: Open-Closed Principle (Open-Closed Principle): Program entities (classes, modules, functions) should be open for extension, but not for modification. In this context, openness to extension is the ability to add new behavior to a class, module, or function if the need arises, and closeness to changes is the prohibition on changing the source code of program entities. At first glance, this sounds complicated and contradictory. But if you look, the principle is quite logical. Following the principle of OCP is that software changes not by changing existing code, but by adding new code. That is, the code originally created remains “intact” and stable, and new functionality is introduced either through implementation inheritance, or through the use of abstract interfaces and polymorphism. So, let's look at the FileLogger class. If we need to write logs to another file, then we can change the name:
class FileLogger{
    fun logError(error: String) {
        val file = File("errors2.txt")
        file.appendText(text = error)
    }
}
But then all the logs will be written to a new file, which, most likely, we do not need. But how can we change our logger without changing the class itself?
open class FileLogger{

    open fun logError(error: String) {
        val file = File("error.txt")
        file.appendText(text = error)
    }

}

class CustomErrorFileLogger : FileLogger() {

    override fun logError(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

}
We mark the FileLogger class and the logError function as open, create a new CustomErrorFileLogger class, and write a new logging implementation. As a result, our class is available for extending functionality, but closed for modification. 3. L: Liskov Substitution Principle  (Barbara Liskov's substitution principle). Subclasses need to be able to serve as replacements for their superclasses. The purpose of this principle is that derived classes can be used in place of the parent classes they are derived from without breaking the program. If it turns out that the class type is checked in the code, then the substitution principle is violated. If we wrote the derived class like this:
class CustomErrorFileLogger : FileLogger() {

    fun customErrorLog(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

}
And now we will replace the FileLogger class with CustomErrorFileLogger in the repository
class MainRepository(
    private val auth: FirebaseAuth,
    private val fileLogger: CustomErrorFileLogger,
) {

    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        }catch(e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }

}
In this case, logError will be called from the parent class, or you will have to change the call to fileLogger.logError(e.message.toString()) to fileLogger.customErrorLog(e.message.toString()) 4. I: Interface  Segregation Principle . Create highly specialized interfaces designed for a specific client. Clients should not depend on interfaces they do not use. It sounds complicated, but it's actually very simple. For example, let's make the FileLogger class an interface, but add one more function to it:
interface FileLogger{

    fun printLogs()

    fun logError(error: String) {
        val file = File("errors.txt")
        file.appendText(text = error)
    }

}
Now all descendants will be required to implement the printLogs function, even if we do not need it in all descendant classes.
class CustomErrorFileLogger : FileLogger{

    override fun printLog() {

    }

    override fun logError(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

}
and now we will have empty functions, which is bad for the cleanliness of the code. Instead, we can make the default value in the interface and then override the function only in those classes that need it:
interface FileLogger{

    fun printLogs() {
        //Howая-то дефолтная реализация
    }

    fun logError(error: String) {
        val file = File("errors.txt")
        file.appendText(text = error)
    }

}
class CustomErrorFileLogger : FileLogger{

    override fun logError(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

}
Now the classes that will implement the FileLogger interface will be cleaner. 5. D: Dependency Inversion Principle  . The dependency object should be an abstraction, not something concrete. Let's go back to our main class:
class MainRepository(
    private val auth: FirebaseAuth,
    private val fileLogger: FileLogger,
) {

    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        } catch (e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }

}
It seems that we have already set everything up and fixed it. However, there is one more thing that needs to be changed. This is using the FirebaseAuth class. What will happen if at some point we need to change the authorization and log in not through Firebase, but, for example, by some api request? Then we will have to change a lot of things, but we would not want to. To do this, we create an interface with the signInWithEmailAndPassword(email: String, password: String) function:
interface Authenticator{
    fun signInWithEmailAndPassword(email: String, password: String)
}
This interface is our abstraction. And now we make specific implementations of the login
class FirebaseAuthenticator : Authenticator{

    override fun signInWithEmailAndPassword(email: String, password: String) {
        FirebaseAuth.getInstance().signInWithEmailAndPassword(email, password)
    }

}
class CustomApiAuthenticator : Authenticator{

    override fun signInWithEmailAndPassword(email: String, password: String) {
        //другой способ логина
    }

}
And in the class `MainRepository` now there is no dependency on a specific implementation, but only on an abstraction
class MainRepository (
    private val auth: Authenticator,
    private val fileLogger: FileLogger,
) {

    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        }catch(e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }

}
And now, in order to change the authorization method, we need to change only one line in the module class.
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION