Skip to Content
Course content

194: Clean Architecture in Kotlin Projects

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're running a high-end restaurant. You have a head chef who writes the recipes, a pantry manager who sources the ingredients, and a waitstaff who serves the guests. Now, if the pantry manager decides to switch from a local farm to a different organic supplier, the head chef doesn't need to rewrite the recipes. Similarly, if you decide to change your dining room layout or switch from dine-in to delivery only, the way the food is actually cooked remains exactly the same.

In software, Clean Architecture is essentially that same separation of concerns. The "Recipe" is your business logic—the core rules of your app that shouldn't care whether you're using a SQL database, a Firebase backend, or a mocked JSON file for testing. I've spent years seeing projects collapse under their own weight because the "Waitstaff" (the UI) was somehow directly talking to the "Pantry" (the database), and any small change in the API forced a rewrite of the entire screen.

Keeping the Business Logic Pure

In Kotlin, we implement this by creating a Domain Layer. This is the heart of your app. It should be a pure Kotlin module with zero dependencies on Android, Retrofit, or Room. If you can't run your domain logic in a plain JUnit test without an emulator, it's not clean.

Let's look at a simple example: a system for managing a user's "Reading List". The domain layer defines what a Book is and the logic for adding one.

// Domain Entity
data class Book(val id: String, val title: String, val author: String)

// Domain Repository Interface (The "Contract")
interface BookRepository {
    suspend fun saveBook(book: Book)
    suspend fun getAllBooks(): List<Book>
}

// Domain Use Case
class AddBookToListUseCase(private val repository: BookRepository) {
    suspend operator fun invoke(book: Book) {
        if (book.title.isBlank()) throw IllegalArgumentException("Title cannot be empty")
        repository.saveBook(book)
    }
}

Notice that AddBookToListUseCase doesn't know how the book is saved. It just knows that whatever BookRepository is passed in will handle the heavy lifting. This is where the power lies.

Handling the Dirty Work in the Data Layer

The Data Layer is where we implement those interfaces. This is where the "Pantry Manager" lives. Here, you deal with API calls, database queries, and data mapping. I always recommend creating "Data Transfer Objects" (DTOs) here so that a change in the API's JSON structure doesn't leak into your Domain entities.

// Data layer implementation
class BookRepositoryImpl(
    private val apiService: BookApiService, 
    private val bookDao: BookDao
) : BookRepository {
    
    override suspend fun saveBook(book: Book) {
        val dto = BookDto(book.id, book.title, book.author) // Map Domain -> DTO
        apiService.uploadBook(dto)
        bookDao.insert(dto.toEntity())
    }

    override suspend fun getAllBooks(): List<Book> {
        return bookDao.getAll().map { it.toDomain() } // Map Entity -> Domain
    }
}

Connecting to the UI via the Presentation Layer

Finally, we have the Presentation Layer. This is your ViewModel and your Compose/XML views. The ViewModel should only talk to the Use Case. It should never touch the Repository directly. Why? Because if the logic for "adding a book" changes (e.g., you now need to check for duplicates first), you only change it in one place—the Use Case—and every screen that adds a book is automatically updated.

class BookViewModel(private val addBookUseCase: AddBookToListUseCase) : ViewModel() {
    fun onAddBookClicked(title: String, author: String) {
        viewModelScope.launch {
            try {
                addBookUseCase(Book(UUID.randomUUID().toString(), title, author))
            } catch (e: Exception) {
                // Handle error in UI
            }
        }
    }
}

By layering it this way, you've built a system where the UI, the Database, and the Business Rules are decoupled. You can swap your database for a web service, or your ViewModel for a different framework, and the core logic—your "recipes"—stays untouched.




📋 Practical Task

Implement a Clean Architecture Weather Feature

Your task is to build the structure for a simple Weather app feature that fetches the current temperature for a city. You need to implement the three layers according to the principles we just discussed.

  • Domain Layer:
    • Create a Weather data class (containing temperature and city name).
    • Create a WeatherRepository interface with a method getCurrentWeather(city: String): Weather.
    • Create a GetWeatherUseCase that calls the repository.
  • Data Layer:
    • Create a WeatherRepositoryImpl that implements the interface. For this exercise, you can simply return a hardcoded Weather object to simulate an API response.
  • Presentation Layer:
    • Create a WeatherViewModel that takes the GetWeatherUseCase as a dependency and has a function to trigger the weather fetch.

Requirement: Ensure the WeatherViewModel never references the WeatherRepositoryImpl directly. The dependency must flow: ViewModel $\rightarrow$ UseCase $\rightarrow$ Repository Interface $\rightarrow$ Repository Implementation.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.