kotlin multiplatform for beginners

Kotlin Multiplatform for Absolute Beginners – Part 3: Writing and Sharing Core Business Logic

Welcome back to our Kotlin Multiplatform journey! In Part 2, we created our first KMP project and saw shared data classes in action. Now we’re ready to dive deeper and build the business logic that will power our Movie Database app.

Today, we’ll transform our simple movie list into a professional, well-architected application with proper separation of concerns, error handling, and shared business logic that both platforms can rely on.

What We’ll Build in This Part

By the end of this tutorial, you’ll have:

  • Repository Pattern: Clean data access layer with clear contracts
  • Business Logic Layer: Validation, data transformation, and complex operations
  • Error Handling: Robust error management across platforms
  • Shared ViewModels: State management that works on both Android and iOS
  • Dependency Injection: Proper component organization and testing setup

Let’s build something production-ready!

kotlin multiplatform for beginners

Understanding Clean Architecture in KMP

Before we start coding, let’s understand how to properly organize our shared business logic. We’ll follow Clean Architecture principles adapted for Kotlin Multiplatform:

shared/src/commonMain/kotlin/
├── data/                    # Data layer
│   ├── repository/         # Repository implementations
│   └── source/             # Data sources (API, local, etc.)
├── domain/                 # Business logic layer  
│   ├── model/              # Data models (we already have this)
│   ├── repository/         # Repository contracts/interfaces
│   └── usecase/            # Business use cases
├── presentation/           # Presentation layer
│   └── viewmodel/          # Shared ViewModels
└── di/                     # Dependency injection

This structure ensures:

  • Clear separation between data access and business logic
  • Testable components with proper dependency injection
  • Platform independence while maintaining flexibility
  • Scalable architecture that grows with your app

Step 1: Creating the Repository Pattern

Let’s start by creating a clean contract for accessing movie data. This pattern allows us to easily switch between different data sources (API, local database, cache) without affecting our business logic.

Defining the Repository Interface

Create shared/src/commonMain/kotlin/domain/repository/MovieRepository.kt:

// shared/src/commonMain/kotlin/domain/repository/MovieRepository.kt
package domain.repository

import domain.model.Movie

/**
 * Contract for accessing movie data from various sources.
 * This interface defines what operations are available without
 * specifying how they're implemented.
 */
interface MovieRepository {
    
    /**
     * Retrieves a list of popular movies
     * @param page The page number for pagination (starting from 1)
     * @return Result containing list of movies or error
     */
    suspend fun getPopularMovies(page: Int = 1): Result<List<Movie>>
    
    /**
     * Searches for movies by title
     * @param query The search query
     * @param page The page number for pagination
     * @return Result containing list of matching movies or error
     */
    suspend fun searchMovies(query: String, page: Int = 1): Result<List<Movie>>
    
    /**
     * Gets detailed information for a specific movie
     * @param movieId The movie ID
     * @return Result containing movie details or error
     */
    suspend fun getMovieDetails(movieId: Int): Result<Movie>
    
    /**
     * Gets a list of favorite movies stored locally
     * @return Result containing list of favorite movies or error
     */
    suspend fun getFavoriteMovies(): Result<List<Movie>>
    
    /**
     * Adds a movie to favorites
     * @param movie The movie to add to favorites
     * @return Result indicating success or failure
     */
    suspend fun addToFavorites(movie: Movie): Result<Unit>
    
    /**
     * Removes a movie from favorites
     * @param movieId The ID of the movie to remove
     * @return Result indicating success or failure
     */
    suspend fun removeFromFavorites(movieId: Int): Result<Unit>
    
    /**
     * Checks if a movie is in the user's favorites
     * @param movieId The movie ID to check
     * @return Result containing boolean indicating if movie is favorited
     */
    suspend fun isFavorite(movieId: Int): Result<Boolean>
}

Creating a Custom Result Type

You’ll notice we’re using a Result type. Let’s create our own error-handling system that works well across platforms:

Create shared/src/commonMain/kotlin/domain/model/Result.kt:

// shared/src/commonMain/kotlin/domain/model/Result.kt
package domain.model

/**
 * A generic wrapper for handling success and error states
 * across all platforms in a consistent way.
 */
sealed class Result<out T> {
    
    /**
     * Represents a successful operation with data
     */
    data class Success<T>(val data: T) : Result<T>()
    
    /**
     * Represents a failed operation with error information
     */
    data class Error<T>(
        val exception: Throwable,
        val message: String = exception.message ?: "Unknown error occurred"
    ) : Result<T>()
    
    /**
     * Represents a loading state (useful for UI)
     */
    data class Loading<T>(val message: String = "Loading...") : Result<T>()
    
    // Convenience methods for working with Results
    
    /**
     * Returns true if this result represents a successful operation
     */
    val isSuccess: Boolean
        get() = this is Success
    
    /**
     * Returns true if this result represents a failed operation
     */
    val isError: Boolean
        get() = this is Error
    
    /**
     * Returns true if this result represents a loading state
     */
    val isLoading: Boolean
        get() = this is Loading
    
    /**
     * Returns the data if success, null otherwise
     */
    fun getOrNull(): T? = when (this) {
        is Success -> data
        else -> null
    }
    
    /**
     * Returns the data if success, or throws the exception if error
     */
    fun getOrThrow(): T = when (this) {
        is Success -> data
        is Error -> throw exception
        is Loading -> throw IllegalStateException("Cannot get data from loading state")
    }
    
    /**
     * Transforms the data if this is a success result
     */
    inline fun <R> map(transform: (T) -> R): Result<R> = when (this) {
        is Success -> try {
            Success(transform(data))
        } catch (e: Exception) {
            Error(e)
        }
        is Error -> Error(exception, message)
        is Loading -> Loading(message)
    }
    
    /**
     * Executes the given action if this is a success result
     */
    inline fun onSuccess(action: (T) -> Unit): Result<T> {
        if (this is Success) action(data)
        return this
    }
    
    /**
     * Executes the given action if this is an error result
     */
    inline fun onError(action: (Throwable) -> Unit): Result<T> {
        if (this is Error) action(exception)
        return this
    }
}

/**
 * Creates a success result
 */
fun <T> Result.Companion.success(data: T): Result<T> = Result.Success(data)

/**
 * Creates an error result
 */
fun <T> Result.Companion.error(exception: Throwable, message: String? = null): Result<T> = 
    Result.Error(exception, message ?: exception.message ?: "Unknown error")

/**
 * Creates a loading result
 */
fun <T> Result.Companion.loading(message: String = "Loading..."): Result<T> = 
    Result.Loading(message)

Implementing the Repository

Now let’s create a concrete implementation of our repository. For now, we’ll use mock data, but this structure will make it easy to switch to real API calls later:

Create shared/src/commonMain/kotlin/data/repository/MovieRepositoryImpl.kt:

// shared/src/commonMain/kotlin/data/repository/MovieRepositoryImpl.kt
package data.repository

import domain.model.Movie
import domain.model.MovieSamples
import domain.model.Result
import domain.repository.MovieRepository
import kotlinx.coroutines.delay

/**
 * Implementation of MovieRepository using mock data.
 * This simulates real network calls with delays and occasional errors.
 */
class MovieRepositoryImpl : MovieRepository {
    
    // Simulate a local favorites storage
    private val favoriteMovies = mutableSetOf<Int>()
    
    override suspend fun getPopularMovies(page: Int): Result<List<Movie>> {
        return try {
            // Simulate network delay
            delay(1000)
            
            // Simulate occasional network errors
            if (Math.random() < 0.1) { // 10% chance of error
                throw Exception("Network connection failed")
            }
            
            // Return paginated results
            val allMovies = MovieSamples.sampleMovies
            val startIndex = (page - 1) * 10
            val endIndex = minOf(startIndex + 10, allMovies.size)
            
            if (startIndex >= allMovies.size) {
                Result.success(emptyList())
            } else {
                Result.success(allMovies.subList(startIndex, endIndex))
            }
            
        } catch (e: Exception) {
            Result.error(e)
        }
    }
    
    override suspend fun searchMovies(query: String, page: Int): Result<List<Movie>> {
        return try {
            delay(800) // Simulate search delay
            
            if (query.isBlank()) {
                return Result.success(emptyList())
            }
            
            // Simple search implementation
            val filteredMovies = MovieSamples.sampleMovies.filter { movie ->
                movie.title.contains(query, ignoreCase = true) ||
                movie.overview.contains(query, ignoreCase = true)
            }
            
            Result.success(filteredMovies)
            
        } catch (e: Exception) {
            Result.error(e)
        }
    }
    
    override suspend fun getMovieDetails(movieId: Int): Result<Movie> {
        return try {
            delay(500)
            
            val movie = MovieSamples.sampleMovies.find { it.id == movieId }
                ?: throw Exception("Movie not found with ID: $movieId")
            
            Result.success(movie)
            
        } catch (e: Exception) {
            Result.error(e)
        }
    }
    
    override suspend fun getFavoriteMovies(): Result<List<Movie>> {
        return try {
            delay(300) // Simulate database access
            
            val favorites = MovieSamples.sampleMovies.filter { 
                favoriteMovies.contains(it.id) 
            }
            
            Result.success(favorites)
            
        } catch (e: Exception) {
            Result.error(e)
        }
    }
    
    override suspend fun addToFavorites(movie: Movie): Result<Unit> {
        return try {
            delay(200)
            favoriteMovies.add(movie.id)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.error(e)
        }
    }
    
    override suspend fun removeFromFavorites(movieId: Int): Result<Unit> {
        return try {
            delay(200)
            favoriteMovies.remove(movieId)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.error(e)
        }
    }
    
    override suspend fun isFavorite(movieId: Int): Result<Boolean> {
        return try {
            delay(100)
            Result.success(favoriteMovies.contains(movieId))
        } catch (e: Exception) {
            Result.error(e)
        }
    }
}

Step 2: Creating Business Use Cases

Use cases encapsulate specific business operations and make our code more testable and maintainable. Let’s create some key use cases for our movie app:

Create shared/src/commonMain/kotlin/domain/usecase/GetPopularMoviesUseCase.kt:

// shared/src/commonMain/kotlin/domain/usecase/GetPopularMoviesUseCase.kt
package domain.usecase

import domain.model.Movie
import domain.model.Result
import domain.repository.MovieRepository

/**
 * Use case for retrieving popular movies with additional business logic
 */
class GetPopularMoviesUseCase(
    private val repository: MovieRepository
) {
    
    /**
     * Executes the use case to get popular movies
     * @param page Page number for pagination
     * @param includeAdult Whether to include adult content
     * @return Result containing filtered and processed movie list
     */
    suspend operator fun invoke(
        page: Int = 1, 
        includeAdult: Boolean = false
    ): Result<List<Movie>> {
        
        // Input validation
        if (page < 1) {
            return Result.error(
                IllegalArgumentException("Page number must be positive"), 
                "Invalid page number"
            )
        }
        
        return when (val result = repository.getPopularMovies(page)) {
            is Result.Success -> {
                val movies = result.data
                
                // Apply business rules
                val filteredMovies = movies
                    .let { movieList ->
                        if (!includeAdult) {
                            movieList.filter { !it.isAdult }
                        } else {
                            movieList
                        }
                    }
                    .sortedByDescending { it.voteAverage } // Sort by rating
                
                Result.success(filteredMovies)
            }
            
            is Result.Error -> result
            is Result.Loading -> result
        }
    }
}

Create shared/src/commonMain/kotlin/domain/usecase/SearchMoviesUseCase.kt:

// shared/src/commonMain/kotlin/domain/usecase/SearchMoviesUseCase.kt
package domain.usecase

import domain.model.Movie
import domain.model.Result
import domain.repository.MovieRepository

/**
 * Use case for searching movies with business logic and validation
 */
class SearchMoviesUseCase(
    private val repository: MovieRepository
) {
    
    /**
     * Searches for movies with the given query
     * @param query Search term
     * @param minRating Minimum rating filter (optional)
     * @return Result containing filtered search results
     */
    suspend operator fun invoke(
        query: String,
        minRating: Double? = null
    ): Result<List<Movie>> {
        
        // Input validation
        if (query.isBlank()) {
            return Result.success(emptyList())
        }
        
        if (query.length < 2) {
            return Result.error(
                IllegalArgumentException("Search query too short"),
                "Please enter at least 2 characters"
            )
        }
        
        return when (val result = repository.searchMovies(query.trim())) {
            is Result.Success -> {
                val movies = result.data
                
                // Apply additional filtering
                val filteredMovies = movies
                    .let { movieList ->
                        if (minRating != null) {
                            movieList.filter { it.voteAverage >= minRating }
                        } else {
                            movieList
                        }
                    }
                    .sortedWith(
                        compareByDescending<Movie> { it.voteAverage }
                            .thenByDescending { it.voteCount }
                    )
                
                Result.success(filteredMovies)
            }
            
            is Result.Error -> result
            is Result.Loading -> result
        }
    }
}

Create shared/src/commonMain/kotlin/domain/usecase/FavoriteMoviesUseCase.kt:

// shared/src/commonMain/kotlin/domain/usecase/FavoriteMoviesUseCase.kt
package domain.usecase

import domain.model.Movie
import domain.model.Result
import domain.repository.MovieRepository

/**
 * Use case for managing favorite movies
 */
class FavoriteMoviesUseCase(
    private val repository: MovieRepository
) {
    
    /**
     * Gets all favorite movies
     */
    suspend fun getFavorites(): Result<List<Movie>> {
        return when (val result = repository.getFavoriteMovies()) {
            is Result.Success -> {
                // Sort favorites by rating, then by title
                val sortedFavorites = result.data.sortedWith(
                    compareByDescending<Movie> { it.voteAverage }
                        .thenBy { it.title }
                )
                Result.success(sortedFavorites)
            }
            
            is Result.Error -> result
            is Result.Loading -> result
        }
    }
    
    /**
     * Toggles favorite status for a movie
     * @param movie The movie to toggle
     * @return Result containing the new favorite status
     */
    suspend fun toggleFavorite(movie: Movie): Result<Boolean> {
        return when (val isFavoriteResult = repository.isFavorite(movie.id)) {
            is Result.Success -> {
                val isFavorite = isFavoriteResult.data
                
                val toggleResult = if (isFavorite) {
                    repository.removeFromFavorites(movie.id)
                } else {
                    repository.addToFavorites(movie)
                }
                
                when (toggleResult) {
                    is Result.Success -> Result.success(!isFavorite)
                    is Result.Error -> toggleResult.map { false }
                    is Result.Loading -> toggleResult.map { false }
                }
            }
            
            is Result.Error -> isFavoriteResult.map { false }
            is Result.Loading -> isFavoriteResult.map { false }
        }
    }
    
    /**
     * Checks if a movie is in favorites
     */
    suspend fun isFavorite(movieId: Int): Result<Boolean> {
        return repository.isFavorite(movieId)
    }
}

Step 3: Creating Shared ViewModels

Now let’s create ViewModels that can be shared between both Android and iOS. These will manage the UI state and handle user interactions:

First, let’s add the necessary dependencies. Update shared/build.gradle.kts:

// Add this to your shared/build.gradle.kts dependencies
commonMain.dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
    implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
}

Create shared/src/commonMain/kotlin/presentation/viewmodel/BaseViewModel.kt:

// shared/src/commonMain/kotlin/presentation/viewmodel/BaseViewModel.kt
package presentation.viewmodel

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

/**
 * Base class for all ViewModels that provides common functionality
 * like coroutine scope management and error handling
 */
abstract class BaseViewModel {
    
    // Create a scope for this ViewModel that will be cancelled when disposed
    private val viewModelJob = SupervisorJob()
    protected val viewModelScope = CoroutineScope(Dispatchers.Main + viewModelJob)
    
    // Common error handling
    private val _errorState = MutableStateFlow<String?>(null)
    val errorState: StateFlow<String?> = _errorState.asStateFlow()
    
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    
    /**
     * Executes a suspend function safely, handling errors and loading states
     */
    protected suspend fun <T> safeExecute(
        showLoading: Boolean = true,
        onError: ((String) -> Unit)? = null,
        action: suspend () -> T
    ): T? {
        return try {
            if (showLoading) _isLoading.value = true
            _errorState.value = null
            
            action()
            
        } catch (e: Exception) {
            val errorMessage = e.message ?: "An unexpected error occurred"
            _errorState.value = errorMessage
            onError?.invoke(errorMessage)
            null
        } finally {
            if (showLoading) _isLoading.value = false
        }
    }
    
    /**
     * Clears the current error state
     */
    fun clearError() {
        _errorState.value = null
    }
    
    /**
     * Dispose of the ViewModel and cancel all coroutines
     */
    open fun dispose() {
        viewModelJob.cancel()
    }
}

Create shared/src/commonMain/kotlin/presentation/viewmodel/MovieListViewModel.kt:

// shared/src/commonMain/kotlin/presentation/viewmodel/MovieListViewModel.kt
package presentation.viewmodel

import domain.model.Movie
import domain.model.Result
import domain.usecase.GetPopularMoviesUseCase
import domain.usecase.SearchMoviesUseCase
import domain.usecase.FavoriteMoviesUseCase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

/**
 * Shared ViewModel for managing movie list state and operations
 */
class MovieListViewModel(
    private val getPopularMoviesUseCase: GetPopularMoviesUseCase,
    private val searchMoviesUseCase: SearchMoviesUseCase,
    private val favoriteMoviesUseCase: FavoriteMoviesUseCase
) : BaseViewModel() {
    
    // UI State
    private val _movies = MutableStateFlow<List<Movie>>(emptyList())
    val movies: StateFlow<List<Movie>> = _movies.asStateFlow()
    
    private val _searchQuery = MutableStateFlow("")
    val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
    
    private val _isSearching = MutableStateFlow(false)
    val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
    
    private val _selectedTab = MutableStateFlow(MovieTab.POPULAR)
    val selectedTab: StateFlow<MovieTab> = _selectedTab.asStateFlow()
    
    // Keep track of favorite status for quick UI updates
    private val _favoriteStatus = MutableStateFlow<Map<Int, Boolean>>(emptyMap())
    val favoriteStatus: StateFlow<Map<Int, Boolean>> = _favoriteStatus.asStateFlow()
    
    init {
        // Load popular movies when ViewModel is created
        loadPopularMovies()
        
        // Set up search functionality
        setupSearch()
    }
    
    /**
     * Loads popular movies
     */
    fun loadPopularMovies() {
        viewModelScope.launch {
            safeExecute {
                when (val result = getPopularMoviesUseCase()) {
                    is Result.Success -> {
                        _movies.value = result.data
                        updateFavoriteStatus(result.data)
                    }
                    is Result.Error -> {
                        _movies.value = emptyList()
                    }
                    is Result.Loading -> {
                        // Loading state is handled by base class
                    }
                }
            }
        }
    }
    
    /**
     * Searches for movies based on query
     */
    fun searchMovies(query: String) {
        _searchQuery.value = query
        
        if (query.isBlank()) {
            loadPopularMovies()
            _isSearching.value = false
            return
        }
        
        _isSearching.value = true
        
        viewModelScope.launch {
            safeExecute(showLoading = false) { // Don't show global loading for search
                when (val result = searchMoviesUseCase(query)) {
                    is Result.Success -> {
                        _movies.value = result.data
                        updateFavoriteStatus(result.data)
                    }
                    is Result.Error -> {
                        _movies.value = emptyList()
                    }
                    is Result.Loading -> {
                        // Handle if needed
                    }
                }
            }
        }
    }
    
    /**
     * Loads favorite movies
     */
    fun loadFavoriteMovies() {
        viewModelScope.launch {
            safeExecute {
                when (val result = favoriteMoviesUseCase.getFavorites()) {
                    is Result.Success -> {
                        _movies.value = result.data
                        updateFavoriteStatus(result.data)
                    }
                    is Result.Error -> {
                        _movies.value = emptyList()
                    }
                    is Result.Loading -> {
                        // Loading handled by base class
                    }
                }
            }
        }
    }
    
    /**
     * Toggles favorite status for a movie
     */
    fun toggleFavorite(movie: Movie) {
        viewModelScope.launch {
            safeExecute(showLoading = false) {
                when (val result = favoriteMoviesUseCase.toggleFavorite(movie)) {
                    is Result.Success -> {
                        val newStatus = result.data
                        _favoriteStatus.value = _favoriteStatus.value.toMutableMap().apply {
                            this[movie.id] = newStatus
                        }
                        
                        // If we're viewing favorites and this movie is no longer a favorite,
                        // remove it from the current list
                        if (_selectedTab.value == MovieTab.FAVORITES && !newStatus) {
                            _movies.value = _movies.value.filter { it.id != movie.id }
                        }
                    }
                    is Result.Error -> {
                        // Error is handled by base class
                    }
                    is Result.Loading -> {
                        // Handle if needed
                    }
                }
            }
        }
    }
    
    /**
     * Changes the selected tab
     */
    fun selectTab(tab: MovieTab) {
        _selectedTab.value = tab
        _searchQuery.value = ""
        _isSearching.value = false
        
        when (tab) {
            MovieTab.POPULAR -> loadPopularMovies()
            MovieTab.FAVORITES -> loadFavoriteMovies()
        }
    }
    
    /**
     * Refreshes the current view
     */
    fun refresh() {
        when (_selectedTab.value) {
            MovieTab.POPULAR -> loadPopularMovies()
            MovieTab.FAVORITES -> loadFavoriteMovies()
        }
    }
    
    /**
     * Sets up reactive search functionality
     */
    private fun setupSearch() {
        // This would typically use a debounce in a real app
        // For now, we'll keep it simple
    }
    
    /**
     * Updates favorite status for a list of movies
     */
    private suspend fun updateFavoriteStatus(movies: List<Movie>) {
        val statusMap = mutableMapOf<Int, Boolean>()
        
        movies.forEach { movie ->
            when (val result = favoriteMoviesUseCase.isFavorite(movie.id)) {
                is Result.Success -> statusMap[movie.id] = result.data
                else -> statusMap[movie.id] = false
            }
        }
        
        _favoriteStatus.value = statusMap
    }
}

/**
 * Represents different tabs/sections in the movie app
 */
enum class MovieTab {
    POPULAR,
    FAVORITES
}

Step 4: Setting Up Dependency Injection

To tie everything together properly, let’s create a simple dependency injection system:

Create shared/src/commonMain/kotlin/di/AppModule.kt:

// shared/src/commonMain/kotlin/di/AppModule.kt
package di

import data.repository.MovieRepositoryImpl
import domain.repository.MovieRepository
import domain.usecase.FavoriteMoviesUseCase
import domain.usecase.GetPopularMoviesUseCase
import domain.usecase.SearchMoviesUseCase
import presentation.viewmodel.MovieListViewModel

/**
 * Simple dependency injection container for the shared module
 */
object AppModule {
    
    // Repository
    private val movieRepository: MovieRepository by lazy {
        MovieRepositoryImpl()
    }
    
    // Use Cases
    private val getPopularMoviesUseCase: GetPopularMoviesUseCase by lazy {
        GetPopularMoviesUseCase(movieRepository)
    }
    
    private val searchMoviesUseCase: SearchMoviesUseCase by lazy {
        SearchMoviesUseCase(movieRepository)
    }
    
    private val favoriteMoviesUseCase: FavoriteMoviesUseCase by lazy {
        FavoriteMoviesUseCase(movieRepository)
    }
    
    // ViewModels
    fun createMovieListViewModel(): MovieListViewModel {
        return MovieListViewModel(
            getPopularMoviesUseCase = getPopularMoviesUseCase,
            searchMoviesUseCase = searchMoviesUseCase,
            favoriteMoviesUseCase = favoriteMoviesUseCase
        )
    }
}

Step 5: Testing Our Business Logic

Let’s add some tests to make sure our business logic works correctly. First, update your shared/build.gradle.kts to include test dependencies:

// Add to your shared/build.gradle.kts
commonTest.dependencies {
    implementation(kotlin("test"))
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}

Create shared/src/commonTest/kotlin/domain/usecase/GetPopularMoviesUseCaseTest.kt:

// shared/src/commonTest/kotlin/domain/usecase/GetPopularMoviesUseCaseTest.kt
package domain.usecase

import domain.model.Movie
import domain.model.MovieSamples
import domain.model.Result
import domain.repository.MovieRepository
import kotlinx.coroutines.test.runTest
import kotlin.test.*

class GetPopularMoviesUseCaseTest {
    
    private val mockRepository = MockMovieRepository()
    private val useCase = GetPopularMoviesUseCase(mockRepository)
    
    @Test
    fun `should return movies when repository succeeds`() = runTest {
        // Given
        val expectedMovies = MovieSamples.sampleMovies
        mockRepository.mockResponse = Result.success(expectedMovies)
        
        // When
        val result = useCase()
        
        // Then
        assertTrue(result.isSuccess)
        assertEquals(expectedMovies.size, result.getOrNull()?.size)
    }
    
    @Test
    fun `should filter adult movies when includeAdult is false`() = runTest {
        // Given
        val moviesWithAdult = listOf(
            MovieSamples.sampleMovies.first(),
            MovieSamples.sampleMovies.first().copy(id = 999, isAdult = true)
        )
        mockRepository.mockResponse = Result.success(moviesWithAdult)
        
        // When
        val result = useCase(includeAdult = false)
        
        // Then
        assertTrue(result.isSuccess)
        val movies = result.getOrNull()!!
        assertFalse(movies.any { it.isAdult })
    }
    
    @Test
    fun `should return error for invalid page number`() = runTest {
        // When
        val result = useCase(page = -1)
        
        // Then
        assertTrue(result.isError)
    }
    
    @Test
    fun `should sort movies by rating descending`() = runTest {
        // Given
        val movies = listOf(
            Movie(1, "Low Rating", "", "", null, null, 5.0, 100),
            Movie(2, "High Rating", "", "", null, null, 9.0, 200),
            Movie(3, "Medium Rating", "", "", null, null, 7.0, 150)
        )
        mockRepository.mockResponse = Result.success(movies)
        
        // When
        val result = useCase()
        
        // Then
        assertTrue(result.isSuccess)
        val sortedMovies = result.getOrNull()!!
        assertEquals(9.0, sortedMovies[0].voteAverage)
        assertEquals(7.0, sortedMovies[1].voteAverage)
        assertEquals(5.0, sortedMovies[2].voteAverage)
    }
    
    // Mock repository for testing
    private class MockMovieRepository : MovieRepository {
        var mockResponse: Result<List<Movie>> = Result.success(emptyList())
        
        override suspend fun getPopularMovies(page: Int): Result<List<Movie>> = mockResponse
        override suspend fun searchMovies(query: String, page: Int): Result<List<Movie>> = Result.success(emptyList())
        override suspend fun getMovieDetails(movieId: Int): Result<Movie> = Result.error(Exception("Not implemented"))
        override suspend fun getFavoriteMovies(): Result<List<Movie>> = Result.success(emptyList())
        override suspend fun addToFavorites(movie: Movie): Result<Unit> = Result.success(Unit)
        override suspend fun removeFromFavorites(movieId: Int): Result<Unit> = Result.success(Unit)
        override suspend fun isFavorite(movieId: Int): Result<Boolean> = Result.success(false)
    }
}

Step 6: Connecting Everything Together

Now let’s update our Android and iOS apps to use our new business logic layer.

Updating Android to Use the ViewModel

Update your Android MainActivity.kt to use the shared ViewModel:

// androidApp/src/main/java/com/yourname/moviedatabase/android/MainActivity.kt
package com.yourname.moviedatabase.android

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import di.AppModule
import domain.model.Movie
import presentation.viewmodel.MovieListViewModel
import presentation.viewmodel.MovieTab

class MainActivity : ComponentActivity() {
    
    private val viewModel = AppModule.createMovieListViewModel()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    MovieApp(viewModel = viewModel)
                }
            }
        }
    }
    
    override fun onDestroy() {
        super.onDestroy()
        viewModel.dispose()
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieApp(viewModel: MovieListViewModel) {
    
    val movies by viewModel.movies.collectAsStateWithLifecycle()
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
    val errorState by viewModel.errorState.collectAsStateWithLifecycle()
    val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
    val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
    val favoriteStatus by viewModel.favoriteStatus.collectAsStateWithLifecycle()
    
    Column {
        // App Bar with search and refresh
        TopAppBar(
            title = { 
                Text(when (selectedTab) {
                    MovieTab.POPULAR -> "Popular Movies"
                    MovieTab.FAVORITES -> "Favorite Movies"
                }) 
            },
            actions = {
                IconButton(onClick = { viewModel.refresh() }) {
                    Icon(Icons.Default.Refresh, contentDescription = "Refresh")
                }
            }
        )
        
        // Search Bar
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { viewModel.searchMovies(it) },
            label = { Text("Search movies...") },
            leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            singleLine = true
        )
        
        // Tab Row
        TabRow(selectedTabIndex = selectedTab.ordinal) {
            Tab(
                selected = selectedTab == MovieTab.POPULAR,
                onClick = { viewModel.selectTab(MovieTab.POPULAR) },
                text = { Text("Popular") }
            )
            Tab(
                selected = selectedTab == MovieTab.FAVORITES,
                onClick = { viewModel.selectTab(MovieTab.FAVORITES) },
                text = { Text("Favorites") }
            )
        }
        
        // Error handling
        errorState?.let { error ->
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
            ) {
                Row(
                    modifier = Modifier.padding(16.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = error,
                        color = MaterialTheme.colorScheme.onErrorContainer,
                        modifier = Modifier.weight(1f)
                    )
                    TextButton(onClick = { viewModel.clearError() }) {
                        Text("Dismiss")
                    }
                }
            }
        }
        
        // Loading indicator
        if (isLoading) {
            Box(
                modifier = Modifier.fillMaxWidth().padding(16.dp),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        }
        
        // Movie list
        LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            items(movies, key = { it.id }) { movie ->
                MovieCard(
                    movie = movie,
                    isFavorite = favoriteStatus[movie.id] ?: false,
                    onFavoriteClick = { viewModel.toggleFavorite(movie) }
                )
            }
            
            // Empty state
            if (movies.isEmpty() && !isLoading) {
                item {
                    Box(
                        modifier = Modifier.fillMaxWidth().padding(32.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = when {
                                searchQuery.isNotBlank() -> "No movies found for \"$searchQuery\""
                                selectedTab == MovieTab.FAVORITES -> "No favorite movies yet"
                                else -> "No movies available"
                            },
                            style = MaterialTheme.typography.bodyLarge
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun MovieCard(
    movie: Movie,
    isFavorite: Boolean,
    onFavoriteClick: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth()
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.Top
            ) {
                Text(
                    text = movie.title,
                    style = MaterialTheme.typography.headlineSmall,
                    fontWeight = FontWeight.Bold,
                    modifier = Modifier.weight(1f)
                )
                
                IconButton(onClick = onFavoriteClick) {
                    Icon(
                        if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
                        contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
                        tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Text(
                text = movie.overview,
                style = MaterialTheme.typography.bodyMedium,
                maxLines = 3
            )
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = movie.getFormattedRating(),
                    style = MaterialTheme.typography.bodySmall
                )
                
                if (movie.isHighlyRated()) {
                    AssistChip(
                        onClick = { },
                        label = { Text("Highly Rated") }
                    )
                }
            }
            
            Text(
                text = "Released: ${movie.releaseDate}",
                style = MaterialTheme.typography.bodySmall
            )
        }
    }
}

Updating iOS to Use the ViewModel

For iOS, we need to create a bridge to work with our Kotlin ViewModel. Create iosApp/iosApp/ViewModelBridge.swift:

// iosApp/iosApp/ViewModelBridge.swift
import Foundation
import shared
import Combine

/**
 * Bridge class to make Kotlin StateFlow work nicely with SwiftUI
 */
class MovieListViewModelBridge: ObservableObject {
    
    private let viewModel: MovieListViewModel
    
    @Published var movies: [Movie] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
    @Published var searchQuery: String = ""
    @Published var selectedTab: MovieTab = .popular
    @Published var favoriteStatus: [Int32: Bool] = [:]
    
    init() {
        self.viewModel = AppModule().createMovieListViewModel()
        
        // Observe StateFlow changes
        observeMovies()
        observeLoadingState()
        observeErrorState()
        observeSearchQuery()
        observeSelectedTab()
        observeFavoriteStatus()
    }
    
    deinit {
        viewModel.dispose()
    }
    
    // MARK: - Public Methods
    
    func loadPopularMovies() {
        viewModel.loadPopularMovies()
    }
    
    func searchMovies(query: String) {
        viewModel.searchMovies(query: query)
    }
    
    func loadFavoriteMovies() {
        viewModel.loadFavoriteMovies()
    }
    
    func toggleFavorite(movie: Movie) {
        viewModel.toggleFavorite(movie: movie)
    }
    
    func selectTab(tab: MovieTab) {
        viewModel.selectTab(tab: tab)
    }
    
    func refresh() {
        viewModel.refresh()
    }
    
    func clearError() {
        viewModel.clearError()
    }
    
    // MARK: - Private Methods
    
    private func observeMovies() {
        viewModel.movies.collect { [weak self] movies in
            DispatchQueue.main.async {
                self?.movies = movies?.compactMap { $0 as? Movie } ?? []
            }
        }
    }
    
    private func observeLoadingState() {
        viewModel.isLoading.collect { [weak self] loading in
            DispatchQueue.main.async {
                self?.isLoading = loading?.boolValue ?? false
            }
        }
    }
    
    private func observeErrorState() {
        viewModel.errorState.collect { [weak self] error in
            DispatchQueue.main.async {
                self?.errorMessage = error
            }
        }
    }
    
    private func observeSearchQuery() {
        viewModel.searchQuery.collect { [weak self] query in
            DispatchQueue.main.async {
                self?.searchQuery = query ?? ""
            }
        }
    }
    
    private func observeSelectedTab() {
        viewModel.selectedTab.collect { [weak self] tab in
            DispatchQueue.main.async {
                self?.selectedTab = tab ?? .popular
            }
        }
    }
    
    private func observeFavoriteStatus() {
        viewModel.favoriteStatus.collect { [weak self] status in
            DispatchQueue.main.async {
                var swiftDict: [Int32: Bool] = [:]
                if let kotlinMap = status {
                    // Convert Kotlin Map to Swift Dictionary
                    for entry in kotlinMap {
                        if let key = entry.key as? Int32, let value = entry.value as? Bool {
                            swiftDict[key] = value
                        }
                    }
                }
                self?.favoriteStatus = swiftDict
            }
        }
    }
}

// Extension to make StateFlow collection work in Swift
extension StateFlow {
    func collect(callback: @escaping (Any?) -> Void) {
        let collector = StateFlowCollector(callback: callback)
        self.collect(collector: collector) { error in
            print("StateFlow collection error: \(error)")
        }
    }
}

class StateFlowCollector: NSObject, FlowCollector {
    private let callback: (Any?) -> Void
    
    init(callback: @escaping (Any?) -> Void) {
        self.callback = callback
    }
    
    func emit(value: Any?) async throws {
        callback(value)
    }
}

Now update iosApp/iosApp/ContentView.swift:

// iosApp/iosApp/ContentView.swift
import SwiftUI
import shared

struct ContentView: View {
    @StateObject private var viewModel = MovieListViewModelBridge()
    
    var body: some View {
        NavigationView {
            VStack {
                // Search Bar
                SearchBar(text: $viewModel.searchQuery) { query in
                    viewModel.searchMovies(query: query)
                }
                
                // Tab Selection
                Picker("Tab", selection: $viewModel.selectedTab) {
                    Text("Popular").tag(MovieTab.popular)
                    Text("Favorites").tag(MovieTab.favorites)
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding(.horizontal)
                .onChange(of: viewModel.selectedTab) { tab in
                    viewModel.selectTab(tab: tab)
                }
                
                // Error Display
                if let error = viewModel.errorMessage {
                    ErrorView(message: error) {
                        viewModel.clearError()
                    }
                }
                
                // Loading Indicator
                if viewModel.isLoading {
                    ProgressView()
                        .scaleEffect(1.2)
                        .padding()
                }
                
                // Movie List
                if viewModel.movies.isEmpty && !viewModel.isLoading {
                    EmptyStateView(
                        selectedTab: viewModel.selectedTab,
                        searchQuery: viewModel.searchQuery
                    )
                } else {
                    List(viewModel.movies, id: \.id) { movie in
                        MovieRow(
                            movie: movie,
                            isFavorite: viewModel.favoriteStatus[movie.id] ?? false
                        ) {
                            viewModel.toggleFavorite(movie: movie)
                        }
                    }
                }
            }
            .navigationTitle(viewModel.selectedTab == .popular ? "Popular Movies" : "Favorites")
            .navigationBarItems(trailing: 
                Button("Refresh") {
                    viewModel.refresh()
                }
            )
        }
        .onAppear {
            viewModel.loadPopularMovies()
        }
    }
}

struct SearchBar: View {
    @Binding var text: String
    let onSearchChanged: (String) -> Void
    
    var body: some View {
        HStack {
            Image(systemName: "magnifyingglass")
                .foregroundColor(.gray)
            
            TextField("Search movies...", text: $text)
                .onChange(of: text) { newValue in
                    onSearchChanged(newValue)
                }
        }
        .padding()
        .background(Color(.systemGray6))
        .cornerRadius(10)
        .padding(.horizontal)
    }
}

struct MovieRow: View {
    let movie: Movie
    let isFavorite: Bool
    let onFavoriteToggle: () -> Void
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text(movie.title)
                    .font(.headline)
                    .fontWeight(.bold)
                
                Spacer()
                
                Button(action: onFavoriteToggle) {
                    Image(systemName: isFavorite ? "heart.fill" : "heart")
                        .foregroundColor(isFavorite ? .red : .gray)
                }
            }
            
            Text(movie.overview)
                .font(.body)
                .lineLimit(3)
            
            HStack {
                Text(movie.getFormattedRating())
                    .font(.caption)
                    .foregroundColor(.secondary)
                
                Spacer()
                
                if movie.isHighlyRated() {
                    Text("Highly Rated")
                        .font(.caption)
                        .padding(.horizontal, 8)
                        .padding(.vertical, 4)
                        .background(Color.blue.opacity(0.2))
                        .cornerRadius(8)
                }
            }
            
            Text("Released: \(movie.releaseDate)")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding(.vertical, 4)
    }
}

struct ErrorView: View {
    let message: String
    let onDismiss: () -> Void
    
    var body: some View {
        HStack {
            Text(message)
                .foregroundColor(.red)
                .font(.caption)
            
            Spacer()
            
            Button("Dismiss", action: onDismiss)
                .font(.caption)
        }
        .padding()
        .background(Color.red.opacity(0.1))
        .cornerRadius(8)
        .padding(.horizontal)
    }
}

struct EmptyStateView: View {
    let selectedTab: MovieTab
    let searchQuery: String
    
    var body: some View {
        VStack {
            Image(systemName: selectedTab == .favorites ? "heart" : "film")
                .font(.system(size: 50))
                .foregroundColor(.gray)
            
            Text(emptyMessage)
                .font(.body)
                .foregroundColor(.gray)
                .multilineTextAlignment(.center)
        }
        .padding()
    }
    
    private var emptyMessage: String {
        if !searchQuery.isEmpty {
            return "No movies found for \"\(searchQuery)\""
        } else if selectedTab == .favorites {
            return "No favorite movies yet"
        } else {
            return "No movies available"
        }
    }
}

What We’ve Accomplished

Congratulations! You’ve just built a robust, production-ready business logic layer for your Kotlin Multiplatform app. Here’s what we’ve created:

Clean Architecture: Separated data, domain, and presentation layers

Repository Pattern: Abstracted data access with clean interfaces
Use Cases: Encapsulated business logic with proper validation

Error Handling: Robust error management across platforms

Shared ViewModels: State management that works on both platforms

Dependency Injection: Proper component organization

Unit Tests: Testable business logic with mock implementations

Your apps now have:

  • Search functionality with proper validation
  • Favorite movies with persistent state
  • Tab-based navigation between popular and favorite movies
  • Error handling with user-friendly messages
  • Loading states for better user experience
  • Reactive UI that updates automatically when data changes

Key Benefits We’ve Gained

  1. Single Source of Truth: All business logic lives in one place
  2. Platform Independence: The same logic works perfectly on both Android and iOS
  3. Maintainability: Changes to business rules only need to be made once
  4. Testability: Each component can be tested in isolation
  5. Scalability: Easy to add new features and data sources

Coming Next: Real API Integration

In Part 4: Connecting Your App to an API (Networking in KMP), we’ll:

  • Replace our mock repository with real API calls
  • Integrate with The Movie Database (TMDB) API
  • Add proper networking with Ktor
  • Implement JSON serialization with kotlinx.serialization
  • Handle network errors and connectivity issues
  • Add image loading for movie posters
  • Implement proper pagination for large datasets

We’ll transform our app from using sample data to fetching real movie information from a live API!

Practice Exercises

Before moving to Part 4, try these exercises:

  1. Add a new use case: Create GetMovieDetailsUseCase for getting detailed movie information
  2. Extend the Movie model: Add properties like runtime, budget, and revenue
  3. Implement sorting: Add options to sort movies by rating, release date, or title
  4. Add filtering: Create filters for genre, release year, or rating range
  5. Write more tests: Add tests for the other use cases and the ViewModel

Great work! You now have a solid foundation of shared business logic that will serve as the backbone of your movie app. The architecture we’ve built will make adding new features much easier and ensure consistent behavior across platforms.