MVI with Clean Architecture

Refactor version of MVI with Clean Architecture

YE MON KYAW
8 min readApr 3, 2023

MVI and MVI with Clean Architecture are both architectural patterns for developing Android applications. However, they differ in the way they handle application logic and data flow. This difference also applies to MVVM and MVVM with clean architecture.

MVI is a pattern that separates an application into three components: Model, View, and Intent. The Model represents the state of the application, the View displays the UI, and the Intent represents user interactions with the UI. The MVI pattern is generally lightweight and straightforward to implement, making it a popular choice for small to medium-sized applications. You can see a detailed explanation in this article.

MVI with Clean Architecture, on the other hand, is a more structured and scalable approach to Android application development. It combines the principles of MVI with the Clean Architecture principles of Separation of Concerns (SoC) and Dependency Inversion (DI).

Clean Architecture provides a clear separation of concerns by dividing an application into layers, where each layer has a specific responsibility and interacts only with specific layers. The layers in Clean Architecture include the Presentation Layer, Domain Layer, and Data Layer.

The Presentation Layer handles user interactions and UI rendering. It is responsible for communicating with the Domain Layer, which contains the application’s business logic. The Data Layer handles data storage and retrieval, interacting with the Domain Layer through defined interfaces.

To illustrate the differences between MVI and MVI with Clean Architecture, let’s consider an example of a Student application. In an MVI application, the Model would contain the student data, the View would display the student data, and the Intent would represent user actions such as adding, editing, or deleting students.

In contrast, in an MVI with a Clean Architecture application, the Presentation Layer would handle UI rendering and user interactions. The Domain Layer would contain the business logic, such as validating student data or retrieving student information from the Data Layer. The Data Layer would be responsible for storing and retrieving student data from a database or remote API.

In the Domain Layer, there might be use cases such as “Add Student,” “Delete Student,” or “Get Student List.” The Presentation Layer would then call these use cases to update the UI or interact with the user.

The advantage of MVI with Clean Architecture is that it provides a more structured and scalable approach. It allows for easier testing, reduces coupling between components, and makes it easier to modify or add new features to the application. However, it can also be more complex to implement and may be overkill for small applications that don’t require a lot of business logic or data storage.

Previous MVI example :

// Define the reducer function to update the state of the application based 
// on user intents
fun reduce(state: AppState, intent: UserIntent): AppState {
return when (intent) {
is UserIntent.AddStudent -> addStudent(state, intent.student)
is UserIntent.EditStudent -> editStudent(state, intent.student)
is UserIntent.DeleteStudent -> deleteStudent(state, intent.student)
}
}

// Define the data model for a student
data class Student(val name: String, val age: Int, val grade: String)

// Define the state of the application
data class AppState(val students: List<Student>)

// Define the actions that the user can perform
sealed class UserIntent {
data class AddStudent(val student: Student) : UserIntent()
data class EditStudent(val student: Student) : UserIntent()
data class DeleteStudent(val student: Student) : UserIntent()
}

// Define the UI component to display the list of
// students and handle user inputs
class StudentListView {
lateinit var presenter: StudentListPresenter

fun render(state: AppState) {
// Render the list of students on the screen
}

fun handleUserInput(intent: UserIntent) {
// Pass the user intent to the presenter to update
// the state of the application
presenter.handleUserIntent(intent)
}
}

// Define the presenter to handle user inputs and
// update the state of the application
class StudentListPresenter(private val reducer: (AppState, UserIntent)
-> AppState) {
lateinit var view: StudentListView
var state = AppState(emptyList())

fun handleUserIntent(intent: UserIntent) {
state = reducer(state, intent)
view.render(state)
}
}

// Usage example
fun main() {
val presenter = StudentListPresenter(::reduce)
val view = StudentListView()
presenter.view = view
view.presenter = presenter

val newStudent = Student("Ye Mon", 18, "F-")
view.handleUserInput(UserIntent.AddStudent(newStudent))

val updatedStudent = Student("Nyan", 19, "A+")
view.handleUserInput(UserIntent.EditStudent(updatedStudent))

val deletedStudent = Student("Thet", 20, "A")
view.handleUserInput(UserIntent.DeleteStudent(deletedStudent))
}


// Define separate functions to handle each type of user intent
fun addStudent(state: AppState, student: Student): AppState {
return state.copy(students = state.students + student)
}

fun editStudent(state: AppState, student: Student): AppState {
val updatedStudents = state.students.map { if (it.name == student.name) student else it }
return state.copy(students = updatedStudents)
}

fun deleteStudent(state: AppState, student: Student): AppState {
val filteredStudents = state.students.filterNot { it.name == student.name }
return state.copy(students = filteredStudents)
}

So let's refactor the previous MVI code to Clean Architecture,

The Domain layer contains the Use Cases, which are responsible for performing the business logic of the application. Each Use Case takes a repository instance as a dependency and encapsulates a specific piece of logic (in this case, adding, editing, deleting, or retrieving students).

The Presentation layer that is responsible for communicating with the user interface and invoking the appropriate Use Cases to perform business logic.

Data Layer the data layer is responsible for fetching and storing data. In this example, we define a StudentRepository interface in the data layer that defines the methods for getting and updating Student data.

ViewModel Layer : the ViewModel layer is responsible for handling the business logic and transforming the StudentIntent into a StudentViewState. In this example, we define a StudentViewModel class that takes in a StudentUseCase as a dependency. At this point don’t confuse with Android ViewModel, Android ViewModel is just making more easily to handle in this layer.

Finally, we’ve instantiated the dependencies (in this case, just the StudentRepository and the Use Cases) using dependency injection. This allows us to easily swap out implementations of these dependencies (e.g. for testing purposes) without modifying the rest of the codebase.

I will not refactor all of the previous example, please do refactor your self which does not included in my example

fun main() {
val database = DatabaseImpl()
val studentRepository = StudentRepositoryImpl(database)
val studentUseCase = StudentUseCase(studentRepository)
val studentViewModel = StudentViewModel(studentUseCase)

// Test loading students
studentViewModel.processIntent(StudentIntent.LoadStudents)
when (val state = studentViewModel.uiViewState) {
is StudentViewState.Loading -> println("Loading students...")
is StudentViewState.Success -> {
println("Loaded ${state.students.size} students:")
state.students.forEach { student ->
println(" - ${student.id} (${student.Name}) - Grade ${student.grade}")
}
}
is StudentViewState.Error -> println("Error loading students: ${state.errorMessage}")
}

// Test updating a student
val studentToUpdate = Student("1", "Nyan", "F")
studentViewModel.processIntent(StudentIntent.EditStudent(studentToUpdate))
// Verify that the student was updated by loading the students again
when (val state = studentViewModel.uiViewState) {
is StudentViewState.Loading -> println("Loading students...")
is StudentViewState.Success -> {
val updatedStudent = state.students.find { it.id == studentToUpdate.id }
if (updatedStudent != null) {
println("Updated ${updatedStudent.Name} (${updatedStudent.id}) - Grade ${updatedStudent.grade}")
} else {
println("Error updating student: student not found")
}
}
is StudentViewState.Error -> println("Error loading students: ${state.errorMessage}")
}

}

/*ViewModel Layer
The ViewModel layer is responsible for handling the business
logic and transforming the StudentIntent into a StudentViewState.
In this example, we define a StudentViewModel
class that takes in a StudentUseCase
(which we'll define next) as a dependency. */
class StudentViewModel(private val studentUseCase: StudentUseCase) {

private var viewState: StudentViewState = StudentViewState.Loading
var uiViewState: StudentViewState = viewState

fun processIntent(intent: StudentIntent) {
when (intent) {
is StudentIntent.LoadStudents -> loadStudents()
is StudentIntent.EditStudent -> {
updateStudent(intent.student)
}
}
}

private fun loadStudents() {
uiViewState = StudentViewState.Loading
try {
val students = studentUseCase.getStudents()
uiViewState = StudentViewState.Success(students)
} catch (e: Exception) {
uiViewState = StudentViewState.Error(e.message ?: "Unknown error")
}
}


private fun updateStudent(student: Student) {
val students = studentUseCase.updateStudent(student)
uiViewState = StudentViewState.Success(students)
}
}

// Define the data model for a student
data class Student(val id: String, val Name: String, val grade: String)

interface Database {
fun getStudents(): List<Student>
fun updateStudent(student : Student) : List<Student>
}

class DatabaseImpl : Database {
private val students = mutableListOf(
Student("1", "Thet", "A"),
Student("2", "Nyan", "A+"),
Student("3", "Ye Mon", "B+")
)

override fun updateStudent(student : Student) : List<Student>{
//should be update data from database according to user given value
//now only mock
val updatedStudents = students.map { if (it.id == student.id) student else it }
return updatedStudents
}

override fun getStudents(): List<Student> {
return students.toList()
}
}

/*
Data Layer
The data layer is responsible for fetching and storing data.
In this example, we define a StudentRepository interface
in the data layer that defines the methods for getting
and updating Student data.
*/
interface StudentRepository {
fun getStudents(): List<Student>
fun updateStudent(student : Student) : List<Student>
}

class StudentRepositoryImpl(private val database: Database) : StudentRepository {
override fun getStudents(): List<Student> {
return database.getStudents()
}

override fun updateStudent(student : Student) : List<Student> {
return database.updateStudent(student)
}
}

/*
Presentation Layer
The presentation layer is responsible for rendering the UI
and responding to user actions. In this example,
we define a StudentViewState sealed class that represents
the different states of the UI.
*/
sealed class StudentViewState {
object Loading : StudentViewState()
data class Success(val students: List<Student>) : StudentViewState()
data class Error(val errorMessage: String) : StudentViewState()
}

// StudentIntent sealed class that represents the different user actions
sealed class StudentIntent {
object LoadStudents : StudentIntent()
data class EditStudent(val student: Student) : StudentIntent()
}

/*
Use Case Layer
The use case layer is responsible for defining the business logic
and interacting with the data layer. In this example, we define
a StudentUseCase class that takes in a StudentRepository as a dependency.
*/
class StudentUseCase(private val studentRepository: StudentRepository) {

fun getStudents(): List<Student> {
return studentRepository.getStudents()
}

fun updateStudent(student: Student) : List<Student> {
return studentRepository.updateStudent(student)
}

}

Now you will see your code is more decouple in each layer than previous MVI pattern by separating different layer and each layer have their own responsibility.

That’s it! with this architecture, the StudentViewModel is responsible for handling the user actions and updating the UI, while the StudentUseCase is responsible for the business logic and interacting with the data layer. This makes it easy to test and maintain the codebase, since each layer has clear responsibilities and dependencies.

Thank you for taking the time to read this article. I hope that my writing has been informative and thought-provoking and you will be more understand about what the different between two different architecture and why we should use clean architecture.

Bonuses : example of how to refactor the StudentViewModel class to follow the Single Responsibility Principle (SRP) and Open/Closed Principle (OCP):

interface StudentViewStateHandler {
fun handleLoading()
fun handleSuccess(students: List<Student>)
fun handleError(message: String)
}

class StudentViewModel(
private val studentUseCase: StudentUseCase,
private val stateHandler: StudentViewStateHandler
) {
fun processIntent(intent: StudentIntent) {
when (intent) {
is StudentIntent.LoadStudents -> loadStudents()
is StudentIntent.UpdateStudent -> updateStudent(intent.student)
}
}

private fun loadStudents() {
stateHandler.handleLoading()
try {
val students = studentUseCase.getStudents()
stateHandler.handleSuccess(students)
} catch (e: Exception) {
stateHandler.handleError(e.message ?: "Unknown error")
}
}

private fun updateStudent(student: Student) {
studentUseCase.updateStudent(student)
}
}


class ConsoleViewStateHandler : StudentViewStateHandler {
override fun handleLoading() {
println("Loading students...")
}

override fun handleSuccess(students: List<Student>) {
println("Loaded ${students.size} students:")
students.forEach { student ->
println(" - ${student.name} (${student.age}) - Grade ${student.grade}")
}
}

override fun handleError(message: String) {
println("Error loading students: $message")
}
}

fun main() {
val studentRepository = StudentRepositoryImpl()
// Replace this with your own implementation
// of the repository interface
val studentUseCase = StudentUseCase(studentRepository)

// Use the ConsoleViewStateHandler to print out the view state
val studentViewModel = StudentViewModel(studentUseCase,
ConsoleViewStateHandler())

// Test loading students
studentViewModel.processIntent(StudentIntent.LoadStudents)

// Test updating a student
val studentToUpdate = Student("1", "Nyan", "A+")
studentViewModel.processIntent(StudentIntent.UpdateStudent(studentToUpdate))

// Verify that the student was updated by loading the students again
studentViewModel.processIntent(StudentIntent.LoadStudents)
}

--

--