How Feature Modules Communicate Through the Data Layer in GSYGitHubAppCompose

Feature modules in GSYGitHubAppCompose communicate exclusively through a shared data layer that exposes repository interfaces, ensuring loose coupling while centralizing caching, network requests, and state management via BaseViewModel.

The GSYGitHubAppCompose project demonstrates a clean architecture where isolated feature modules—such as Trending, Issue, Home, and RepoDetail—never reference each other directly. Instead, they communicate through a standardized data layer that abstracts local database access and remote API calls behind repository interfaces.

The Data Layer Architecture

The data layer resides in /data/src/main/java/com/shuyu/gsygithubappcompose/data/ and provides the sole communication channel for all feature modules. It consists of three core components that standardize how data flows through the application.

RepositoryResult and DataSource Abstraction

At the heart of the communication pattern sits the RepositoryResult wrapper class defined in /data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/RepositoryResult.kt. This class encapsulates the actual data payload alongside a DataSource enum indicating whether the data originated from CACHE or NETWORK.

// RepositoryResult.kt
data class RepositoryResult<T>(
    val data: Result<T>,
    val dataSource: DataSource,
    val isDbEmpty: Boolean
)

enum class DataSource {
    CACHE, NETWORK
}

This abstraction allows feature modules to react differently to cached versus fresh data without knowing the underlying implementation details.

Repository Classes

Concrete repositories such as TrendingRepository and IssueRepository expose methods returning Flow<RepositoryResult<T>>. These classes coordinate between local DAOs and remote API services. For example, TrendingRepository.kt combines TrendingDao database access with apiService network calls to provide a unified data stream.

How Feature Modules Request Data Through Repositories

Feature modules obtain data by injecting the appropriate repository into their ViewModel, which extends BaseViewModel. The communication follows a strict four-step flow:

  1. Feature ViewModel initiates requests by calling repository methods that return Flow<RepositoryResult<T>>
  2. Repository emits cached data first (if available), then fetches network data and emits a second result marked with DataSource.NETWORK
  3. BaseViewModel orchestrates state updates using handleResult() to merge data, manage pagination, and toggle loading indicators
  4. UI layer observes StateFlow updates through the uiState property exposed by BaseViewModel

This pattern ensures that every feature module communicates with the data layer identically, regardless of the specific data type being requested.

The Trending feature demonstrates the complete communication cycle. The TrendingViewModel injects TrendingRepository and delegates all loading logic to BaseViewModel methods.

// TrendingViewModel.kt (feature/trending)
@HiltViewModel
class TrendingViewModel @Inject constructor(
    private val trendingRepository: TrendingRepository,
    preferencesDataStore: UserPreferencesDataStore,
    private val stringResourceProvider: StringResourceProvider
) : BaseViewModel<TrendingUiState>( /* … */ ) {

    override fun loadData(initialLoad: Boolean, isRefresh: Boolean, isLoadMore: Boolean) {
        launchDataLoadWithUser(initialLoad, isRefresh, isLoadMore) { _, _ ->
            trendingRepository.getTrendingRepositories(since = "daily", languageType = null)
                .collect { repoResult ->
                    repoResult.data.fold(
                        onSuccess = { newRepos ->
                            handleResult(
                                newItems = newRepos,
                                pageToLoad = 1,
                                isRefresh = isRefresh,
                                initialLoad = initialLoad,
                                isLoadMore = false,
                                source = repoResult.dataSource,
                                isDbEmpty = repoResult.isDbEmpty,
                                updateSuccess = { state, items, _, _, _, _ ->
                                    state.copy(repositories = items, hasMore = false)
                                },
                                updateFailure = { state, _, _ ->
                                    state.copy(repositories = emptyList(), hasMore = false)
                                }
                            )
                        },
                        onFailure = { ex ->
                            updateErrorState(ex, isLoadMore,
                                stringResourceProvider.getString(R.string.error_failed_to_load_repositories))
                        }
                    )
                }
        }
    }
}

The TrendingRepository implements the dual-source emission pattern:

// TrendingRepository.kt (data/repository)
fun getTrendingRepositories(since: String, languageType: String?): Flow<RepositoryResult<List<TrendingRepoModel>>> = flow {
    var isDbEmpty = false
    val cached = trendingDao.getAllTrendingRepos()
    isDbEmpty = cached.isEmpty()
    if (!isDbEmpty) {
        emit(RepositoryResult(Result.success(cached.map { it.toTrendingRepoModel() }), DataSource.CACHE, isDbEmpty))
    }
    try {
        val response = apiService.getTrendingRepos(since, languageType)
        trendingDao.clearTrendingRepos()
        trendingDao.insertAll(response.map { it.toTrendingEntity() })
        emit(RepositoryResult(Result.success(response), DataSource.NETWORK, isDbEmpty))
    } catch (e: Exception) {
        emit(RepositoryResult(Result.failure(e), DataSource.NETWORK, isDbEmpty))
    }
}

The repository first serves cached data from trendingDao, then updates the database after a successful network call, and finally emits fresh data marked with DataSource.NETWORK.

Cross-Module Data Sharing Without Direct Coupling

The Issue feature demonstrates how modules share contextual data without creating dependencies. IssueViewModel (located in feature/issue/src/main/java/com/shuyu/gsygithubappcompose/feature/issue/IssueViewModel.kt) injects both IssueRepository and UserPreferencesDataStore to perform permission checks—comparing the current username against repository owners or comment authors—without directly accessing other feature modules.

// IssueViewModel.kt (feature/issue)
class IssueViewModel @Inject constructor(
    private val issueRepository: IssueRepository,
    private val preferencesDataStore: UserPreferencesDataStore,
    private val stringResourceProvider: StringResourceProvider,
    savedStateHandle: SavedStateHandle
) : BaseViewModel<IssueUiState>( /* … */ ) {

    override fun loadData(initialLoad: Boolean, isRefresh: Boolean, isLoadMore: Boolean) {
        if (initialLoad || isRefresh) fetchIssueInfo(...)
        fetchIssueComments(..., isLoadMore)
    }
}

Because UserPreferencesDataStore resides in the shared data layer (core/common/datastore/UserPreferencesDataStore.kt), multiple features can access user-specific settings and authentication state simultaneously while maintaining strict module boundaries.

Summary

  • Feature modules never reference each other directly; they communicate solely through repository interfaces exposed by the data layer.
  • BaseViewModel standardizes communication by handling loading states, pagination logic, and error management across all features via methods like handleResult() and launchDataLoadWithUser().
  • RepositoryResult abstracts data sources, allowing features to differentiate between cached and network data without implementation coupling.
  • Shared data stores like UserPreferencesDataStore enable cross-feature logic such as permission checks while preserving module isolation.

Frequently Asked Questions

How do feature modules avoid direct dependencies on each other?

Feature modules depend only on the data layer's repository interfaces and shared data stores. They inject specific repositories (such as TrendingRepository or IssueRepository) into their ViewModels rather than importing classes from other feature modules. This inversion of control ensures that the Trending module knows nothing about the Issue module's implementation, and vice versa.

What role does BaseViewModel play in feature communication?

BaseViewModel—defined in /data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/vm/BaseViewModel.kt—provides generic machinery for data fetching and state management. It exposes a StateFlow<UI_STATE> that all feature UIs observe, and implements handleResult() to merge paginated data, toggle loading indicators, and process errors. By inheriting from BaseViewModel, every feature module communicates with the data layer using identical patterns.

How does the data layer prioritize cache versus network data?

Repositories emit data sequentially through Kotlin Flow. They first query the local DAO and emit a RepositoryResult marked with DataSource.CACHE, then perform the network request, update the database, and emit a second result marked with DataSource.NETWORK. This allows the UI to display cached content immediately while refreshing in the background.

Can feature modules share user-specific data without tight coupling?

Yes. The data layer provides UserPreferencesDataStore at core/common/datastore/UserPreferencesDataStore.kt, which exposes the current username and settings as flows. Any feature module can inject this store to perform logic—such as checking if the current user owns a repository—without importing classes from other features, maintaining strict architectural boundaries while enabling shared context.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →