Hello everyone! Today, I want to share the story behind a significant transformation in my open-source Android project, SimpMusic.
For those who don't know, SimpMusic is a lightweight, privacy-focused music player for Android. Like many passion projects, it started simple. My initial architecture was a straightforward MVVM pattern, which worked well at first. However, as the app grew, I started hitting some familiar walls.
These were my main pain points:
- Difficult Maintenance: A small change in one area often had unforeseen consequences in another. The codebase was becoming fragile.
- Low Scalability: Adding new features felt like a complex surgery. The code was tightly coupled, and I knew this approach wasn't sustainable.
- Unclear Data Flow: It was becoming hard to track how data moved from the server or database to the screen.
To tackle these challenges head-on, I decided to undertake a major refactoring effort: migrating SimpMusic to Clean Architecture. This article documents that journey.
Why Clean Architecture?
Before diving into the "how," let's briefly cover the "why." Clean Architecture, proposed by Robert C. Martin (Uncle Bob), is a software design philosophy that enforces a clear separation of concerns. It divides a project into distinct layers:
- Presentation: The UI layer (Activities, Fragments, ViewModels). It knows nothing about how data is stored or retrieved.
- Domain: The core of the application. It contains the business logic (Use Cases) and business objects (Entities). This layer is completely independent of any framework, including the Android SDK.
- Data: This layer is responsible for providing data to the Domain layer, either from a remote server (API) or a local database.
The golden rule is the Dependency Rule: source code dependencies can only point inwards. The Data and Presentation layers depend on the Domain layer, but the Domain layer depends on nothing.
This structure promised to solve my initial problems by creating a system that is independent of the UI, database, and frameworks, making it inherently more testable, maintainable, and scalable.
Furthermore, a key motivation for this migration was the prospect of Kotlin Multiplatform (KMP). A highly modular project is a prerequisite for sharing code via KMP, and this architecture sets the stage perfectly for that future.
The Migration Journey: A Look Inside SimpMusic's New Structure
I restructured the project into several distinct Gradle modules to enforce separation of concerns. The core of the architecture consists of three main modules: :app (Presentation), :domain, and :data.
Alongside these, I created several utility and feature modules:
- :common: This module holds shared code used across the entire project, such as loggers, constants, and other utility classes.
- :media: This module is dedicated to handling all logic related to Android's Media3 library, encapsulating media playback functionality in one place.
- :service: This module is responsible for all third-party API interactions. It contains the logic for communicating with services like the YouTube API, Spotify, lyrics providers, and any AI-related services.
Here’s a breakdown of the main architectural layers.
1. The Domain Layer: The Heart of the App
This was the first layer I built. It defines the core logic and policies of SimpMusic.
- Entities: Plain Kotlin data classes representing the core concepts, like model, entity, etc. They have no annotations or dependencies.
- Repository Interfaces: These are simple interfaces that define a contract for what the Data layer must implement. The Domain layer owns these interfaces but knows nothing about their implementation.
package com.maxrave.domain.repository
import android.graphics.Bitmap
import com.maxrave.domain.data.entities.QueueEntity
import com.maxrave.domain.data.entities.SongEntity
import com.maxrave.domain.data.entities.SongInfoEntity
import com.maxrave.domain.data.model.browse.album.Track
import com.maxrave.domain.data.model.download.DownloadProgress
import com.maxrave.domain.data.model.streams.YouTubeWatchEndpoint
import com.maxrave.domain.utils.Resource
import kotlinx.coroutines.flow.Flow
import java.time.LocalDateTime
interface SongRepository {
fun getAllSongs(limit: Int): Flow\<List\<SongEntity\>\>
suspend fun setInLibrary(
videoId: String,
inLibrary: LocalDateTime,
)
fun getSongsByListVideoId(listVideoId: List\<String\>): Flow\<List\<SongEntity\>\>
}
// https://github.com/maxrave-dev/SimpMusic/blob/jetpack\_compose/core/domain/src/main/java/com/maxrave/domain/repository/SongRepository.kt
- There are no use case layers, because the use case is a repository wrapper and I think it is not necessary.
2. The Data Layer: The Source of Truth
This layer provides the concrete implementation for the repository interfaces defined in the Domain layer. It acts as a bridge, using data sources from the :service module or a local database.
- Repository Implementation: This is where the magic happens. SongRepositoryImpl implements the SongRepository interface and decides where to fetch data from—a remote API (via the :service module) or a local database.
package com.maxrave.data.repository
internal class SongRepositoryImpl(
private val localDataSource: LocalDataSource,
private val youTube: YouTube,
) : SongRepository {
override fun getAllSongs(limit: Int): Flow\<List\<SongEntity\>\> \=
flow {
emit(localDataSource.getAllSongs(limit))
}.flowOn(Dispatchers.IO)
}
// https://github.com/maxrave-dev/SimpMusic/blob/jetpack\_compose/core/data/src/main/java/com/maxrave/data/repository/SongRepositoryImpl.kt
I make it internal, because this should not be used by other modules
- Mappers: Simple functions to convert data models into domain models (Song).
3. The Presentation Layer: The Face of the App
This layer is what the user sees and interacts with. I used the MVVM pattern here.
- UI (Composables): The UI's only job is to observe state from the ViewModel and render it on the screen. It captures user input and forwards it to the ViewModel.
- ViewModels: ViewModels are responsible for holding and managing UI-related state. They call the repositories to perform actions and receive the results. They have no reference to Activities or Fragments.
Challenges and Lessons Learned
The migration wasn't without its challenges.
- Boilerplate Code: There's no denying that Clean Architecture introduces more classes and interfaces. Setting up entities, repositories, and mappers for each feature requires more initial effort.
- The Learning Curve: Understanding the strict separation and the Dependency Rule took some time to get used to.
- Decision Fatigue: Sometimes, it was tricky to decide where a specific piece of logic should reside.
However, overcoming these challenges taught me the importance of discipline in software design. The initial "slowness" pays off massively in the long run.
The Payoff: Tangible Benefits
So, was it all worth it? Absolutely.
- Supreme Testability: This was the biggest win. I can now write fast, reliable unit tests for my ViewModels and Repositories without needing an emulator. The business logic is fully decoupled from the Android framework.
- Effortless Scalability: For instance, adding a new "Lyrics" feature became a streamlined process. I simply needed to update the repository and connect the data to the ViewModel. No other part of the app was affected.
- A Maintainable Codebase: The code is now organized, predictable, and easy to navigate. When a bug appears, its location is almost always obvious because of the clear separation of concerns.
- Future-Proofing and KMP-Ready: This is perhaps the biggest long-term benefit. The modular structure minimizes the need for large-scale refactoring in the future. More importantly, it lays the perfect foundation for a gradual migration to Kotlin Multiplatform (KMP), making it significantly easier to share code between Android and other platforms down the line.
Conclusion
Migrating SimpMusic to Clean Architecture was a significant investment of time, but it has paid for itself many times over in terms of code quality, maintainability, and my own development speed. It transformed a fragile codebase into a robust, scalable, and genuinely enjoyable project to work on.
If you're feeling the "growing pains" in your own Android project, I highly encourage you to explore a modular architecture.
What's next for SimpMusic? The next big step is to embrace Compose Multiplatform, with the initial focus on building a desktop version.
Call to Action
- You can check out the complete source code on:
https://github.com/maxrave-dev/SimpMusic - Download and try the app from:
https://www.simpmusic.org/download - You can also find me on: