Writing Clean Architecture in Kotlin: Best Practices

Clean Architecture is a software design philosophy aimed at creating maintainable, testable, and scalable applications. Kotlin, with its expressive syntax and modern features, is an excellent language for implementing Clean Architecture principles. This blog post explores the best practices for writing Clean Architecture in Kotlin.

What is Clean Architecture?

Clean Architecture, proposed by Robert C. Martin (Uncle Bob), emphasizes the separation of concerns, making the application resilient to changes in the UI, database, or external services. It structures code into layers with distinct responsibilities and dependencies that flow inward.

Core Principles of Clean Architecture

  • Independence of Frameworks: The architecture should not depend on the existence of some library of features.
  • Testability: The business rules can be tested without the UI, database, or any other external element.
  • Independence of UI: The UI can change easily, without changing the system’s core logic.
  • Independence of Database: You should be able to switch to Oracle, SQL Server, MongoDB, or BigTable, without affecting the business rules.
  • Independence of Any External Agency: Business rules shouldn’t know anything at all about interfaces to the outside world.

Layers in Clean Architecture

  • Entities: Encapsulate Enterprise wide business rules.
  • Use Cases: Application specific business rules.
  • Interface Adapters: Converts data from the format most convenient for the use cases and entities, to the format most convenient for some external agency.
  • Frameworks and Drivers: Glue logic which sits right at the outer edge.

Implementing Clean Architecture in Kotlin: Best Practices

Here’s how to implement Clean Architecture in Kotlin effectively:

Step 1: Project Structure

Organize your project into distinct modules or packages representing the layers. A common structure includes:


src/main/kotlin/
├── domain/       # Entities and Use Cases
│   ├── entities/
│   │   ├── User.kt
│   │   └── ...
│   └── usecases/
│       ├── GetUserUseCase.kt
│       ├── CreateUserUseCase.kt
│       └── ...
├── data/         # Interface Adapters (Repositories, Data Sources)
│   ├── repositories/
│   │   ├── UserRepository.kt
│   │   ├── UserRepositoryImpl.kt
│   │   └── ...
│   ├── datasources/
│   │   ├── local/
│   │   │   ├── UserLocalDataSource.kt
│   │   │   ├── UserLocalDataSourceImpl.kt
│   │   │   └── ...
│   │   ├── remote/
│   │   │   ├── UserRemoteDataSource.kt
│   │   │   ├── UserRemoteDataSourceImpl.kt
│   │   │   └── ...
│   └── models/     # Data Transfer Objects (DTOs)
│       ├── UserDTO.kt
│       └── ...
├── presentation/  # Frameworks and Drivers (UI, ViewModels)
│   ├── viewmodels/
│   │   ├── UserViewModel.kt
│   │   └── ...
│   ├── activities/
│   │   ├── UserActivity.kt
│   │   └── ...
│   └── ...
└── di/             # Dependency Injection
    ├── AppModule.kt
    └── ...

Step 2: Domain Layer

The domain layer contains entities (business objects) and use cases (business logic). Entities should be pure Kotlin data classes without any framework dependencies.

Entities

// src/main/kotlin/domain/entities/User.kt
package domain.entities

data class User(
    val id: Int,
    val name: String,
    val email: String
)
Use Cases

Use cases define the application-specific business rules. They orchestrate the entities to perform a specific task.


// src/main/kotlin/domain/usecases/GetUserUseCase.kt
package domain.usecases

import domain.entities.User

interface GetUserUseCase {
    suspend operator fun invoke(userId: Int): Result
}

class GetUserUseCaseImpl(private val userRepository: UserRepository) : GetUserUseCase {
    override suspend operator fun invoke(userId: Int): Result {
        return userRepository.getUser(userId)
    }
}

Step 3: Data Layer

The data layer is responsible for fetching and persisting data. It includes repositories and data sources.

Repository Interface

The repository interface defines the methods for data access.


// src/main/kotlin/data/repositories/UserRepository.kt
package data.repositories

import domain.entities.User

interface UserRepository {
    suspend fun getUser(userId: Int): Result
}
Repository Implementation

The repository implementation fetches data from local or remote data sources and converts them to domain entities.


// src/main/kotlin/data/repositories/UserRepositoryImpl.kt
package data.repositories

import data.datasources.local.UserLocalDataSource
import data.datasources.remote.UserRemoteDataSource
import domain.entities.User

class UserRepositoryImpl(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) : UserRepository {
    override suspend fun getUser(userId: Int): Result {
        return try {
            val localUser = localDataSource.getUser(userId)
            if (localUser != null) {
                Result.success(localUser)
            } else {
                val remoteUser = remoteDataSource.getUser(userId)
                if (remoteUser != null) {
                    localDataSource.saveUser(remoteUser)  // Cache the remote data
                    Result.success(remoteUser)
                } else {
                    Result.failure(Exception("User not found"))
                }
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}
Data Sources

Data sources provide access to different data storage mechanisms (local database, remote API, etc.).


// src/main/kotlin/data/datasources/remote/UserRemoteDataSource.kt
package data.datasources.remote

import domain.entities.User

interface UserRemoteDataSource {
    suspend fun getUser(userId: Int): User?
}

class UserRemoteDataSourceImpl(private val apiService: ApiService) : UserRemoteDataSource {
    override suspend fun getUser(userId: Int): User? {
        return try {
            val userDto = apiService.getUser(userId)
            userDto?.toDomainEntity()
        } catch (e: Exception) {
            null
        }
    }
}

// src/main/kotlin/data/datasources/local/UserLocalDataSource.kt
package data.datasources.local

import domain.entities.User

interface UserLocalDataSource {
    suspend fun getUser(userId: Int): User?
    suspend fun saveUser(user: User)
}

class UserLocalDataSourceImpl(private val userDao: UserDao) : UserLocalDataSource {
    override suspend fun getUser(userId: Int): User? {
        return userDao.getUser(userId)
    }

    override suspend fun saveUser(user: User) {
        userDao.insert(user)
    }
}

Step 4: Presentation Layer

The presentation layer includes UI components (Activities, Fragments, Composable functions) and ViewModels.

ViewModel

// src/main/kotlin/presentation/viewmodels/UserViewModel.kt
package presentation.viewmodels

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import domain.entities.User
import domain.usecases.GetUserUseCase
import kotlinx.coroutines.launch

class UserViewModel(private val getUserUseCase: GetUserUseCase) : ViewModel() {

    private val _user = MutableLiveData()
    val user: LiveData get() = _user

    private val _errorMessage = MutableLiveData()
    val errorMessage: LiveData get() = _errorMessage

    fun fetchUser(userId: Int) {
        viewModelScope.launch {
            getUserUseCase(userId)
                .onSuccess { user ->
                    _user.value = user
                }
                .onFailure { e ->
                    _errorMessage.value = e.message
                }
        }
    }
}
UI Component (Activity/Composable)

// src/main/kotlin/presentation/activities/UserActivity.kt
package presentation.activities

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import presentation.viewmodels.UserViewModel
import di.AppModule // Import the DI module you have

class UserActivity : AppCompatActivity() {

    private lateinit var viewModel: UserViewModel
    private lateinit var userNameTextView: TextView
    private lateinit var userEmailTextView: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_user)

        userNameTextView = findViewById(R.id.userNameTextView)
        userEmailTextView = findViewById(R.id.userEmailTextView)

        // ViewModel Injection
        val getUserUseCase = AppModule.provideGetUserUseCase(this)
        val viewModelFactory = UserViewModelFactory(getUserUseCase)

        viewModel = ViewModelProvider(this, viewModelFactory).get(UserViewModel::class.java)

        // Observe the LiveData
        viewModel.user.observe(this, Observer { user ->
            user?.let {
                userNameTextView.text = it.name
                userEmailTextView.text = it.email
            }
        })

        viewModel.errorMessage.observe(this, Observer { message ->
            message?.let {
                // Display error message (e.g., Toast)
                println("Error: $it")
            }
        })

        // Fetch User data
        viewModel.fetchUser(123)
    }
}


class UserViewModelFactory(
    private val getUserUseCase: domain.usecases.GetUserUseCase
) : ViewModelProvider.Factory {
    override fun  create(modelClass: Class): T {
        if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return UserViewModel(getUserUseCase) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Step 5: Dependency Injection

Use Dependency Injection (DI) to manage dependencies and decouple layers. Dagger, Hilt, or Koin are popular DI frameworks in Kotlin.

Using Hilt

Add dependencies to your build.gradle file:


dependencies {
    implementation("com.google.dagger:hilt-android:2.48")
    kapt("com.google.dagger:hilt-compiler:2.48")
}

Create modules to provide dependencies:


// src/main/kotlin/di/AppModule.kt
package di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import data.datasources.local.UserLocalDataSource
import data.datasources.local.UserLocalDataSourceImpl
import data.datasources.remote.UserRemoteDataSource
import data.datasources.remote.UserRemoteDataSourceImpl
import data.repositories.UserRepository
import data.repositories.UserRepositoryImpl
import domain.usecases.GetUserUseCase
import domain.usecases.GetUserUseCaseImpl
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideUserLocalDataSource(): UserLocalDataSource {
        // Provide the implementation for local data source, e.g., Room database
        // Here, replace the implementation
        // return UserLocalDataSourceImpl(roomDatabase.userDao())
        TODO("Provide actual implementation here based on your local data source like Room.")
    }

    @Provides
    @Singleton
    fun provideUserRemoteDataSource(): UserRemoteDataSource {
        // Provide the implementation for remote data source, e.g., Retrofit
        // return UserRemoteDataSourceImpl(retrofitApiService)
        TODO("Provide actual implementation here based on your remote API service using Retrofit or another library.")
    }


    @Provides
    @Singleton
    fun provideUserRepository(
        localDataSource: UserLocalDataSource,
        remoteDataSource: UserRemoteDataSource
    ): UserRepository {
        return UserRepositoryImpl(localDataSource, remoteDataSource)
    }


    @Provides
    @Singleton
    fun provideGetUserUseCase(userRepository: UserRepository): GetUserUseCase {
        return GetUserUseCaseImpl(userRepository)
    }

    // Example usage (for mocking tests purposes in Unit Tests)
    fun provideGetUserUseCase(context: android.content.Context): GetUserUseCase {
        // Provide the necessary context or components to instantiate the implementation of GetUserUseCase if necessary
        // Return GetUserUseCase instance (implementation of GetUserUseCase interface.)
        val localDataSource: UserLocalDataSource = UserLocalDataSourceImpl(DummyUserDao())
        val remoteDataSource: UserRemoteDataSource = UserRemoteDataSourceImpl(DummyApiService())

        val userRepository = UserRepositoryImpl(localDataSource, remoteDataSource)

        return GetUserUseCaseImpl(userRepository) // Returns the instance implementation of this contract/interface.
    }

    class DummyUserDao : data.datasources.local.UserDao{ // implement from data.datasources.local.UserDao interface
        override suspend fun getUser(userId: Int): domain.entities.User? {
            return domain.entities.User(userId, "Test Name", "Test Email")
        }
        override suspend fun insert(user: domain.entities.User) {
           return println("Dummy Insert User Implementation Here")
        }
    }

    class DummyApiService: data.datasources.remote.ApiService{ // Implement from  data.datasources.remote.ApiService interface
        override suspend fun getUser(userId: Int): data.models.UserDTO? {
            return data.models.UserDTO(userId, "Test Name DTO", "Test Email DTO")
        }
    }
}

Step 6: Exception Handling

Use Kotlin’s Result type for handling operations that can fail, especially in the domain and data layers.


override suspend fun getUser(userId: Int): Result {
    return try {
        val userDto = apiService.getUser(userId)
        if (userDto != null) {
            Result.success(userDto.toDomainEntity())
        } else {
            Result.failure(Exception("User not found"))
        }
    } catch (e: Exception) {
        Result.failure(e)
    }
}

Step 7: Testing

Write unit tests for each layer. The domain layer should be pure unit tests, while the data and presentation layers might require more elaborate testing strategies.

Example Unit Test for Use Case

import domain.entities.User
import domain.usecases.GetUserUseCase
import data.repositories.UserRepository
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock

class GetUserUseCaseTest {

    @Test
    fun `invoke returns success result with user`() = runBlocking {
        val userRepository = mock(UserRepository::class.java)
        val getUserUseCase = GetUserUseCaseImpl(userRepository)
        val expectedUser = User(1, "Test User", "test@example.com")

        `when`(userRepository.getUser(1)).thenReturn(Result.success(expectedUser))

        val result = getUserUseCase(1)
        assertEquals(Result.success(expectedUser), result)
    }

    @Test
    fun `invoke returns failure result when user is not found`() = runBlocking {
        val userRepository = mock(UserRepository::class.java)
        val getUserUseCase = GetUserUseCaseImpl(userRepository)
        val errorMessage = "User not found"

        `when`(userRepository.getUser(1)).thenReturn(Result.failure(Exception(errorMessage)))

        val result = getUserUseCase(1)
        assertEquals(errorMessage, (result.exceptionOrNull() as Exception).message)
    }
}

Conclusion

Writing Clean Architecture in Kotlin involves organizing code into layers with clear responsibilities and dependencies. By following these best practices—structuring your project, defining entities and use cases, creating repositories and data sources, implementing ViewModels, using dependency injection, and handling exceptions—you can build maintainable, testable, and scalable applications. Embrace Kotlin’s features to simplify and enhance your Clean Architecture implementation.