- Published on
OOP Mastery – Theory 04: SOLID Principle
- Authors
- Name
- Dan Tech
- @dan_0xff
Android Architecture Pattern is a series of articles about architectures and principles in the software development process.
Please read the previous article: Architecture Pattern: MVC, MVP, MVVM
SOLID is a set of cornerstone rules in software design and development. The purpose of SOLID is to make the written software more maintainable, developable, and easily updated.
Single Responsibility Principle (SRP)
Statement of SRP: Each entity in the software (e.g. variable, function, class, module) should only take on a single task.
Refer to the code snippet below:
class MainActivity : AppCompatActivity() {
private val apiService: ApiService by lazy { get() }
private lateinit var binding: MainActivityBinding
override fun onCreate(savedInstanceState: Bundle) {
super.onCreate(savedInstanceState)
binding = MainActivityBinding.inflate(layoutInflater)
setContent(binding.root)
lifecycleScope.launch {
val myProfile = withContext(Dispatchers.IO) { apiService.getMyProfile() }
binding.myProfileUiData = myProfile.mapUiData()
binding.executePendingBindings()
}
}
}
In the code above, the main purpose of the MainActivity class is to display the interface and receive user events, but it also has the logic to execute network calls from the apiService variable -> this is a bad practice in software development because it does not follow SRP.
How to fix it properly:
class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
private lateinit var binding: MainActivityBinding
override fun onCreate(savedInstanceState: Bundle) {
super.onCreate(savedInstanceState)
binding = MainActivityBinding.inflate(layoutInflater)
setContent(binding.root)
lifecycleScope.launch {
mainViewModel.myProfileStateFlow.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { it ->
binding.myProfileUiData = it
binding.executePendingBindings()
}
}
}
}
class MainViewModel : ViewModel() {
private val apiService: ApiService by lazy { get() }
private val _myProfileStateFlow: MutableStateFlow = MutableStateFlow(MyProfileUiState.empty)
private val myProfileStateFlow: StateFlow = _myProfileStateFlow
init {
fetchMyProfile()
}
fun fetchMyProfile() {
viewModelScope.launch {
val myProfile = withContext(Dispatchers.IO) { apiService.getMyProfile() }
_myProfileStateFlow.value = myProfile.mapUiData()
}
}
}
The code after being modified has separated the ViewModel, you can still continue to separate it into Use-Cases, or separate the ApiService to be in the Data Layer instead of being directly in the ViewModel. Try applying it!
Open-Closed Principle (OCP)
Statement of OCP: Entities (variable, function, class, module) in the software should be designed to support inheritance and extension (Open). Should not be designed as closed (Closed).
Refer to the following example to understand more clearly:
class ImageLoader {
private val network: Network by lazy { get() }
suspend fun loadImage(url: String): Bitmap? {
return network.request(url).getBitmap() // -> simulate download image from network and cast it to Bitmap
}
}
The above design simulates a class with the task of loading an image from the Internet. During development, we come up with the feature of loadImage from local file path, so what should we do?
Solution 1: Modify the loadImage function in ImageLoader
class ImageLoader {
private val network: Network by lazy { get() }
private val local: Local by lazy { get simulate download image from network and cast it to Bitmap
} else {
return local.load(url).getBitmap() // simulate download image from local file path and cast it to Bitmap
}
}
}
Basically, this code can run and still ensure correct execution, but it encounters a problem that the same loadImage function in ImageLoader is taking on two roles: loading images from the Internet and loading images from Local. It violates SRP, and according to OCP that we just learned, a class should be Open for extension and Close for modification. So this approach is not reasonable!
Solution 2: Change the Class design
interface ImageLoader {
suspend fun loadImage(url: String): Bitmap?
}
class LoadImageFromNetwork: ImageLoader {
private val network: Network by lazy { get() }
override suspend fun loadImage(url: String): Bitmap? {
return network.request(url).getBitmap() // -> simulate download image from network and cast it to Bitmap
}
}
class LoadImageFromLocal: ImageLoader {
private val local: Local by lazy { get
## Liskov Substitution Principle (LSP)
Liskov -> Proper name
Substitution -> can be replaced
Statement of LSP: In software development, when designing a Class, an Object of the Parent Class must be replaceable by the Child Class without changing the correctness of the program.
This is the most academic and difficult to understand Principle in the set of 5 principles. Read more of the code below!
```kotlin
open class NotesLocalDataStore {
val dataStore: LocalDataStore by lazy { get() }
open fun getNote(id: Long): Note? {
return dataStore.fetchNoteItem(id)?.mapNote()
}
}
class NotesSQLite: NotesLocalDataStore {
val dataBase: SQLiteDatabase by lazy { get() }
override fun getNote(id: Long): Note? {
return null
}
fun getNote(id: String): Note? {
val cursor = dataBase.rawQuery("SELECT * from notes WHERE id = $id")
return Note(cursor.first())
}
}
In the above example, it can be seen that the NotesSQLite class has broken the Liskov Substitution rule by directly returning a null value for the getNote(id: Long) function and adding a getNote(id: String) function to serve its SQLite query.
The fix for this case is also quite easy. You can refer to it.
open class NotesLocalDataStore {
val dataStore: LocalDataStore by lazy { get() }
open fun getNote(id: Long): Note? {
return dataStore.fetchNoteItem(id)?.mapNote()
}
}
class NotesSQLite: NotesLocalDataStore {
val dataBase: SQLiteDatabase by lazy { get() }
override fun getNote(id: Long): Note? {
val idString = id.toString()
val cursor = dataBase.rawQuery("SELECT * from notes WHERE id = $idString")
return Note(cursor.first())
}
}
Interface Segregation Principle (ISP)
Segregation -> Division
This is also an interesting Principle, I will quote its original English statement 'A client should never be forced to implement an interface that it doesn't use, or clients shouldn't be forced to depend on methods they do not use.'
Statement of ISP: Code in the program should not be able to Reference features that they do not need to use.
This statement means that in a function, class, or module, there should be no redundant references to features that the function, class, or module does not need to use. It can be understood that this Principle also has similarities with the Single Responsibility Principle, which is also not wrong.
To solve this Interface Segregation Principle, we often approach the problem, designing Interfaces so that they are as small as possible, as independent as possible to facilitate the implementation of neat and clean code.
Refer to the following code example:
interface ApiService {
suspend fun getMyProfile(): Profile
suspend fun getUserProfile(id: String): Profile
suspend fun follow(id: String): FollowResult
suspend fun unfollow(id: String): UnfollowResult
suspend fun getNewsFeed(timestamp: Long): NewsResult
suspend fun likePost(id: String): LikeResult
suspend fun hidePost(id: String): HidePost
}
The design of the ApiService Interface is covering too many features that are not always needed. This breaks the Interface Segregation Principle. The solution is to break the ApiService into several different Interfaces to make the usage and reuse clearer.
interface UserApiService {
suspend fun getMyProfile(): Profile
suspend fun getUserProfile(id: String): Profile
suspend fun follow(id: String): FollowResult
suspend fun unfollow(id: String): UnfollowResult
}
interface NewsFeedApiService {
suspend fun getNewsFeed(timestamp: Long): NewsResult
suspend fun likePost(id: String): LikeResult
suspend fun hidePost(id: String): HidePost
}
Dependency Inversion Principle (DIP)
Dependency: Dependence
Inversion: Reverse
Statement of DIP: Different classes and modules do not depend on each other. They depend on the abstract layer.
Again, this is a rather academic and difficult to imagine Principle. Let me explain it to you guys. We are almost famous and successful at this stage, keep going.
class NoteDataSource {
val database: Database by lazy { get() }
suspend fun getNote(id: Long): Note? {
return database.getNote(id)?.mapNote()
}
suspend fun deleteNote(id: Long): Int {
return database.deleteNote(id)
}
suspend fun updateNote(id: Long, content: String): Int {
return database.updateNote(id, content)
}
}
class MainViewModel(private val noteDataSource: NoteDataSource) : ViewModel() {
}
In the example above, it can be seen that the NoteDataSource class is passed directly into MainViewModel, leading to a dependency between MainViewModel and NoteDataSource. This code still runs correctly and still follows the Single Responsibility Principle, so why is the Dependency Inversion Principle needed to solve what?
Problems when not using DIP
- Separate encapsulation: In terms of methodology, when only depending on abstract, you can mock these dependencies and test them with pre-written software scenarios. The same is true when you perform writing tests for the Implementations of the Interfaces passed in. This means that everything is separate and does not depend on each other. This architecture increases the testability of the system.
- Depending on detail of implementation makes the source code rigid, difficult to replace and develop. In the case where you need to replace NoteDataSource with a different approach, you are forced to update the NoteDataSource class (-> this violates Open Closed) or inherit the NoteDataSource class into a new class and pass that Object back into MainViewModel to use. This change causes the source code to change a lot, according to the software principle, the more code changes, the more difficult it is to test + verify.
- Depending on detail implementation instead of abstract also brings a major obstacle for Developers in managing the dependency graph in the case where 2 classes are in 2 different modules -> causing complexity in codebase management.
interface NoteDataSource {
suspend fun getNote(id: Long): Note?
suspend fun deleteNote(id: Long): Int
suspend fun updateNote(id: Long, content: String): Int
}
class NoteDataSourceImpl : NoteDataSource {
val database: Database by lazy { get() }
override suspend fun getNote(id: Long): Note? {
return database.getNote(id)?.mapNote()
}
override suspend fun deleteNote(id: Long): Int {
return database.deleteNote(id)
}
override suspend fun updateNote(id: Long, content: String): Int {
return database.updateNote(id, content)
}
}
class MainViewModel(private val noteDataSource: NoteDataSource) : ViewModel() {
}
So please apply the Dependency Inversion Principle thoroughly!