Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Hygiene this promote: closed GH **#46** (nav — F-102/111/112) and **#43** (hyb

| ID | Status | Title | Notes |
|----|--------|-------|-------|
| F-116 | todo | Sample clean architecture (domain use cases) | GH **#48** · **`priority: now`** · **`cron may continue`**. Gold sample: weaken presentation↔data coupling via **domain use cases**; kill `GlobalScope` / data-layer scope ownership (see legacy `CharacterPageDataSource` / `CharactersListViewModel` pattern if still present). Keep flat role graph — domain as `api`/`impl` (or util) ports, no `impl`→`impl`, no `androidLibrary`. Docs: `docs/SAMPLE-APP.md` + any progressive pointer if teaching-worthy. Close #48 on merge. |
| F-116 | done | Sample clean architecture (domain use cases) | GH **#48**. Domain use cases on characters `core` (`IGetCharactersUseCase` / `IGetCharacterUseCase`); list paging via `viewModelScope` (no `GlobalScope`); detail VM on use case. Docs: `docs/SAMPLE-APP.md`. Close #48 on merge. |

## Backlog (lower priority / historical GitHub)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package tools.forma.sample.feature.characters.core.api.di

import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharacterUseCase
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharactersUseCase

interface CharactersCoreFeature {

fun getMarvelRepository(): MarvelRepository
}

fun getCharactersUseCase(): IGetCharactersUseCase

fun getCharacterUseCase(): IGetCharacterUseCase
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package tools.forma.sample.feature.characters.core.api.domain.usecase

import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter

interface IGetCharacterUseCase {

suspend operator fun invoke(id: Long): ICharacter
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package tools.forma.sample.feature.characters.core.api.domain.usecase

import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter

interface IGetCharactersUseCase {

suspend operator fun invoke(offset: Int, limit: Int): List<ICharacter>
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,48 @@ import tools.forma.sample.feature.characters.core.api.data.response.CharacterRes
import tools.forma.sample.feature.characters.core.api.data.service.MarvelService
import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharacterUseCase
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharactersUseCase
import tools.forma.sample.feature.characters.core.impl.data.mapper.CharacterMapper
import tools.forma.sample.feature.characters.core.impl.domain.repository.ServiceMarvelRepository
import tools.forma.sample.feature.characters.core.impl.domain.usecase.GetCharacterUseCase
import tools.forma.sample.feature.characters.core.impl.domain.usecase.GetCharactersUseCase
import dagger.Binds
import dagger.Module
import dagger.Provides
import retrofit2.Retrofit
import javax.inject.Singleton

@Module
internal class CharactersCoreModule {
internal abstract class CharactersCoreModule {

@Singleton
@Provides
fun provideMarvelService(retrofit: Retrofit): MarvelService =
retrofit.create(MarvelService::class.java)
@Binds
abstract fun bindGetCharactersUseCase(useCase: GetCharactersUseCase): IGetCharactersUseCase

@Singleton
@Provides
fun provideMarvelRepository(
service: MarvelService,
config: Config,
clock: Clock,
characterMapper: Mapper<BaseResponse<CharacterResponse>, List<ICharacter>>,
): MarvelRepository =
ServiceMarvelRepository(service, config, clock, characterMapper)

@Provides
fun provideCharacterMapper(): Mapper<BaseResponse<CharacterResponse>, List<ICharacter>> =
CharacterMapper()
}
@Binds
abstract fun bindGetCharacterUseCase(useCase: GetCharacterUseCase): IGetCharacterUseCase

companion object {

@Singleton
@Provides
fun provideMarvelService(retrofit: Retrofit): MarvelService =
retrofit.create(MarvelService::class.java)

@Singleton
@Provides
fun provideMarvelRepository(
service: MarvelService,
config: Config,
clock: Clock,
characterMapper: Mapper<BaseResponse<CharacterResponse>, List<ICharacter>>,
): MarvelRepository =
ServiceMarvelRepository(service, config, clock, characterMapper)

@Provides
fun provideCharacterMapper(): Mapper<BaseResponse<CharacterResponse>, List<ICharacter>> =
CharacterMapper()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package tools.forma.sample.feature.characters.core.impl.domain.usecase

import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharacterUseCase
import javax.inject.Inject

class GetCharacterUseCase @Inject constructor(
private val repository: MarvelRepository,
) : IGetCharacterUseCase {

override suspend fun invoke(id: Long): ICharacter =
repository.getCharacter(id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package tools.forma.sample.feature.characters.core.impl.domain.usecase

import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharactersUseCase
import javax.inject.Inject

class GetCharactersUseCase @Inject constructor(
private val repository: MarvelRepository,
) : IGetCharactersUseCase {

override suspend fun invoke(offset: Int, limit: Int): List<ICharacter> =
repository.getCharacters(offset, limit)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharacterUseCase
import tools.forma.sample.feature.characters.detail.api.presentation.ICharacterDetailViewModel
import tools.forma.sample.feature.characters.detail.api.presentation.ICharacterDetailViewState
import tools.forma.sample.feature.characters.favorite.api.domain.usecase.IGetCharacterFavoriteUseCase
Expand All @@ -30,7 +30,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject

class CharacterDetailViewModel @Inject constructor(
private val marvelRepository: MarvelRepository,
private val getCharacterUseCase: IGetCharacterUseCase,
private val getCharacterFavoriteUseCase: IGetCharacterFavoriteUseCase,
private val setCharacterFavoriteUseCase: ISetCharacterFavoriteUseCase,
) : ViewModel(), ICharacterDetailViewModel {
Expand All @@ -47,7 +47,7 @@ class CharacterDetailViewModel @Inject constructor(
_state.postValue(CharacterDetailViewState.Loading)
viewModelScope.launch {
try {
val result = marvelRepository.getCharacter(characterId)
val result = getCharacterUseCase(characterId)
_data.postValue(result)

getCharacterFavoriteUseCase(characterId)?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,25 @@ import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import tools.forma.sample.core.network.library.NetworkState
import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharactersUseCase
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

const val PAGE_INIT_ELEMENTS = 0
const val PAGE_MAX_ELEMENTS = 50

/**
* Incremental data loader for page-keyed content, where requests return keys for next/previous
* pages. Obtaining paginated the Marvel characters.
* Incremental data loader for page-keyed content. Thin paging adapter: loads via
* [IGetCharactersUseCase] on a [CoroutineScope] owned by the presentation layer
* (typically [androidx.lifecycle.viewModelScope]). Does not own a scope or call
* the repository directly.
*
* @see PageKeyedDataSource
*/
// TODO https://github.com/formatools/forma/issues/48
// Rewrite on clean version with separate Repository with local/remote datasource
open class CharacterPageDataSource(
private val repository: MarvelRepository,
private val getCharactersUseCase: IGetCharactersUseCase,
private val scope: CoroutineScope,
) : PageKeyedDataSource<Int, ICharacter>() {

val networkState = MutableLiveData<NetworkState>()
Expand All @@ -48,16 +49,13 @@ open class CharacterPageDataSource(
callback: LoadInitialCallback<Int, ICharacter>
) {
networkState.postValue(NetworkState.Loading())
// TODO https://github.com/formatools/forma/issues/48
// Don't do that!!! Using GlobalScope here only for first working version
// Make it from UseCase and calling it from View Model scope
GlobalScope.launch(CoroutineExceptionHandler { _, _ ->
scope.launch(CoroutineExceptionHandler { _, _ ->
retry = {
loadInitial(params, callback)
}
networkState.postValue(NetworkState.Error())
}) {
val response = repository.getCharacters(
val response = getCharactersUseCase(
offset = PAGE_INIT_ELEMENTS,
limit = PAGE_MAX_ELEMENTS
)
Expand All @@ -79,16 +77,13 @@ open class CharacterPageDataSource(
callback: LoadCallback<Int, ICharacter>
) {
networkState.postValue(NetworkState.Loading(true))
// TODO https://github.com/formatools/forma/issues/48
// Don't do that!!! Using GlobalScope here only for first working version
// Make it from UseCase and calling it from View Model scope
GlobalScope.launch(CoroutineExceptionHandler { _, _ ->
scope.launch(CoroutineExceptionHandler { _, _ ->
retry = {
loadAfter(params, callback)
}
networkState.postValue(NetworkState.Error(true))
}) {
val response = repository.getCharacters(
val response = getCharactersUseCase(
offset = params.key,
limit = PAGE_MAX_ELEMENTS
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,21 @@ package tools.forma.sample.feature.characters.list.impl.data.datasource
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import javax.inject.Inject
import javax.inject.Provider
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharactersUseCase
import kotlinx.coroutines.CoroutineScope

/**
* Data source factory which also provides a way to observe the last created data source.
* This allows us to channel its network request status etc back to the UI.
*
* Constructed by the ViewModel with [IGetCharactersUseCase] and the ViewModel's
* [CoroutineScope] so paging loads never use a process-global scope.
*
* @see DataSource.Factory
*/
@Deprecated("Seems this class invoke some domain logic. It's wrong!", ReplaceWith("On target UseCase"))
class CharactersPageDataSourceFactory @Inject constructor(
private val repository: MarvelRepository,
class CharactersPageDataSourceFactory(
private val getCharactersUseCase: IGetCharactersUseCase,
private val scope: CoroutineScope,
) : DataSource.Factory<Int, ICharacter>() {

var sourceLiveData = MutableLiveData<CharacterPageDataSource>()
Expand All @@ -43,7 +45,7 @@ class CharactersPageDataSourceFactory @Inject constructor(
* @see DataSource.Factory.create
*/
override fun create(): DataSource<Int, ICharacter> {
val dataSource = CharacterPageDataSource(repository)
val dataSource = CharacterPageDataSource(getCharactersUseCase, scope)
sourceLiveData.postValue(dataSource)
return dataSource
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,10 @@
package tools.forma.sample.feature.characters.list.impl.di

import androidx.lifecycle.ViewModel
import tools.forma.sample.core.di.library.scopes.FeatureScope
import tools.forma.sample.core.mvvm.library.di.ViewModelKey
import tools.forma.sample.feature.characters.core.api.domain.repository.MarvelRepository
import tools.forma.sample.feature.characters.list.impl.data.datasource.CharacterPageDataSource
import tools.forma.sample.feature.characters.list.impl.ui.CharactersListViewModel
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap

@Module
Expand All @@ -34,15 +30,4 @@ internal abstract class CharactersListModule {
@IntoMap
@ViewModelKey(CharactersListViewModel::class)
abstract fun bindsCharactersListViewModel(viewModel: CharactersListViewModel): ViewModel

companion object {

@FeatureScope
@Provides
fun providesCharactersPageDataSource(
repository: MarvelRepository,
) = CharacterPageDataSource(
repository = repository,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,29 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import tools.forma.sample.core.mvvm.library.lifecycle.SingleLiveData
import tools.forma.sample.core.network.library.NetworkState
import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.core.api.domain.usecase.IGetCharactersUseCase
import tools.forma.sample.feature.characters.list.impl.data.datasource.CharactersPageDataSourceFactory
import tools.forma.sample.feature.characters.list.impl.data.datasource.PAGE_MAX_ELEMENTS
import tools.forma.sample.feature.characters.core.api.domain.model.ICharacter
import tools.forma.sample.feature.characters.list.viewbinding.domain.model.ICharactersListViewEvent
import tools.forma.sample.feature.characters.list.viewbinding.domain.model.ICharactersListViewModel
import tools.forma.sample.feature.characters.list.viewbinding.domain.model.ICharactersListViewState
import javax.inject.Inject

class CharactersListViewModel @Inject constructor(
// TODO https://github.com/formatools/forma/issues/48
// Aggregate UseCase here
private val dataSourceFactory: CharactersPageDataSourceFactory
getCharactersUseCase: IGetCharactersUseCase,
) : ViewModel(), ICharactersListViewModel {

private val dataSourceFactory = CharactersPageDataSourceFactory(
getCharactersUseCase = getCharactersUseCase,
scope = viewModelScope,
)

override val networkState = dataSourceFactory.sourceLiveData.switchMap {
it.networkState
}
Expand Down
16 changes: 16 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

Newest entries first.

## 2026-08-06 — F-116: sample domain use cases; kill GlobalScope

- **Ticket:** F-116 → `done` (GH #48)
- **Branch:** `forma/F-116-sample-clean-arch` (from `origin/v2`)
- **Actions:**
- Added `IGetCharactersUseCase` / `IGetCharacterUseCase` on `feature/characters/core/api`; impls + `@Binds` in `core/impl`; exposed on `CharactersCoreFeature`
- List paging: `CharacterPageDataSource` takes use case + `CoroutineScope` (from VM `viewModelScope`); zero `GlobalScope`; no direct `MarvelRepository`
- `CharactersListViewModel` depends on `IGetCharactersUseCase`; owns factory with scope
- `CharacterDetailViewModel` uses `IGetCharacterUseCase` (not repository)
- Docs: `docs/SAMPLE-APP.md` characters layering section
- **Verify (real):**
- `application/` `./gradlew :feature-characters-list-impl:compileDebugKotlin :feature-characters-detail-impl:compileDebugKotlin :feature-characters-core-impl:compileDebugKotlin :binary:assembleDebug` → **BUILD SUCCESSFUL** in 2m30s
- `grep -R GlobalScope application --include='*.kt'` → empty (product sources)
- **Not in slice:** Paging3 migration; Forma engine changes; progressive example module
- **Next:** close GH #48 on merge; board open item remains F-094 (human Portal)

## 2026-08-06 — P13: promote remaining open GH into TICKETS

- **Context:** Empty gated coding board post F-115/#250; Stepan: **Promote gh issues**
Expand Down
Loading
Loading