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

SOLID principles that will make your code cleaner

Published in the Random EN group
What is SOLID? Here's what 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. bricks 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 they are 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, and we use it to try to log in. If we receive an error in response, 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 be responsible for only one thing. If a class is responsible for solving several problems, its subsystems that implement the solution to these problems are connected to each other. Changes in one such subsystem lead to changes in another. In our example, the loginUser function is responsible for two things - login and writing an error to a file. In order for there to be one responsibility, the logic for recording an error must be placed 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 has been 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 we will do this in the FileLogger class, and not in the loginUser 2 function. O: Open-Closed  Principle: Software entities (classes, modules, functions) must 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 closedness to change is the prohibition on changing the source code of software entities. At first glance, this sounds complicated and contradictory. But if you look at it, the principle is quite logical. Following the OCP principle is that software is changed not by changing existing code, but by adding new code. That is, the originally created code 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, we can change the name:
class FileLogger{
    fun logError(error: String) {
        val file = File("errors2.txt")
        file.appendText(text = error)
    }
}
But then all 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 is closed for modification. 3. L: Liskov Substitution Principle  . It is necessary that subclasses can serve as replacements for their superclasses. The purpose of this principle is that descendant classes can be used in place of the parent classes from which they are derived without breaking the program. If it turns out that the code is checking the type of a class, then the substitution principle is violated. If we wrote the successor class like this:
class CustomErrorFileLogger : FileLogger() {

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

}
And now let's 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 code cleanliness. Instead, we can make a default value in the interface and then override the function only in those classes in which it is needed:
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 object of the dependency 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 configured and fixed everything here. But there is still one more point that needs to be changed. This is using the FirebaseAuth class. What will happen if at some point we need to change authorization and log in not through Firebase, but, for example, using some kind of API request? Then we will have to change a lot of things, and we wouldn’t want that. To do this, create an interface with the function signInWithEmailAndPassword(email: String, password: String):
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 `MainRepository` class there is now no dependence on the specific implementation, but only on the 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, 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