diff --git a/TICKETS.md b/TICKETS.md index e04ef731..1c7c1ac9 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -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) diff --git a/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/di/CharactersCoreFeature.kt b/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/di/CharactersCoreFeature.kt index 5648d48f..a47a55ff 100644 --- a/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/di/CharactersCoreFeature.kt +++ b/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/di/CharactersCoreFeature.kt @@ -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 -} \ No newline at end of file + + fun getCharactersUseCase(): IGetCharactersUseCase + + fun getCharacterUseCase(): IGetCharacterUseCase +} diff --git a/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/domain/usecase/IGetCharacterUseCase.kt b/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/domain/usecase/IGetCharacterUseCase.kt new file mode 100644 index 00000000..4a41bd1a --- /dev/null +++ b/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/domain/usecase/IGetCharacterUseCase.kt @@ -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 +} diff --git a/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/domain/usecase/IGetCharactersUseCase.kt b/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/domain/usecase/IGetCharactersUseCase.kt new file mode 100644 index 00000000..b5fedb22 --- /dev/null +++ b/application/feature/characters/core/api/src/main/java/tools/forma/sample/feature/characters/core/api/domain/usecase/IGetCharactersUseCase.kt @@ -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 +} diff --git a/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/di/CharactersCoreModule.kt b/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/di/CharactersCoreModule.kt index 655fe79a..a96dcec3 100644 --- a/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/di/CharactersCoreModule.kt +++ b/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/di/CharactersCoreModule.kt @@ -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, List>, - ): MarvelRepository = - ServiceMarvelRepository(service, config, clock, characterMapper) - - @Provides - fun provideCharacterMapper(): Mapper, List> = - CharacterMapper() -} \ No newline at end of file + @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, List>, + ): MarvelRepository = + ServiceMarvelRepository(service, config, clock, characterMapper) + + @Provides + fun provideCharacterMapper(): Mapper, List> = + CharacterMapper() + } +} diff --git a/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/domain/usecase/GetCharacterUseCase.kt b/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/domain/usecase/GetCharacterUseCase.kt new file mode 100644 index 00000000..e0dd7012 --- /dev/null +++ b/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/domain/usecase/GetCharacterUseCase.kt @@ -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) +} diff --git a/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/domain/usecase/GetCharactersUseCase.kt b/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/domain/usecase/GetCharactersUseCase.kt new file mode 100644 index 00000000..bbba75bf --- /dev/null +++ b/application/feature/characters/core/impl/src/main/java/tools/forma/sample/feature/characters/core/impl/domain/usecase/GetCharactersUseCase.kt @@ -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 = + repository.getCharacters(offset, limit) +} diff --git a/application/feature/characters/detail/impl/src/main/java/tools/forma/sample/feature/characters/detail/impl/presentation/CharacterDetailViewModel.kt b/application/feature/characters/detail/impl/src/main/java/tools/forma/sample/feature/characters/detail/impl/presentation/CharacterDetailViewModel.kt index d260fe20..e2e86c09 100644 --- a/application/feature/characters/detail/impl/src/main/java/tools/forma/sample/feature/characters/detail/impl/presentation/CharacterDetailViewModel.kt +++ b/application/feature/characters/detail/impl/src/main/java/tools/forma/sample/feature/characters/detail/impl/presentation/CharacterDetailViewModel.kt @@ -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 @@ -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 { @@ -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 { diff --git a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSource.kt b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSource.kt index 4210eab5..54e98d20 100644 --- a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSource.kt +++ b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSource.kt @@ -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() { val networkState = MutableLiveData() @@ -48,16 +49,13 @@ open class CharacterPageDataSource( callback: LoadInitialCallback ) { 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 ) @@ -79,16 +77,13 @@ open class CharacterPageDataSource( callback: LoadCallback ) { 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 ) diff --git a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSourceFactory.kt b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSourceFactory.kt index f9832d40..e2c2c503 100644 --- a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSourceFactory.kt +++ b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/data/datasource/CharactersPageDataSourceFactory.kt @@ -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() { var sourceLiveData = MutableLiveData() @@ -43,7 +45,7 @@ class CharactersPageDataSourceFactory @Inject constructor( * @see DataSource.Factory.create */ override fun create(): DataSource { - val dataSource = CharacterPageDataSource(repository) + val dataSource = CharacterPageDataSource(getCharactersUseCase, scope) sourceLiveData.postValue(dataSource) return dataSource } diff --git a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/di/CharactersListModule.kt b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/di/CharactersListModule.kt index 8e08b551..3dd5af30 100644 --- a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/di/CharactersListModule.kt +++ b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/di/CharactersListModule.kt @@ -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 @@ -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, - ) - } } diff --git a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/ui/CharactersListViewModel.kt b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/ui/CharactersListViewModel.kt index 39d976ae..0c4228e5 100644 --- a/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/ui/CharactersListViewModel.kt +++ b/application/feature/characters/list/impl/src/main/java/tools/forma/sample/feature/characters/list/impl/ui/CharactersListViewModel.kt @@ -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 } diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3c9881e2..2ef4f925 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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** diff --git a/docs/SAMPLE-APP.md b/docs/SAMPLE-APP.md index 8c7ae02f..c74b891e 100644 --- a/docs/SAMPLE-APP.md +++ b/docs/SAMPLE-APP.md @@ -31,7 +31,7 @@ application/ └── feature/ ├── home/{api,impl,res,viewbinding} └── characters/ - ├── core/{api,impl} (shared domain + Marvel API) + ├── core/{api,impl} (shared domain + Marvel API + use cases) ├── list/{api,impl,res,viewbinding} ├── detail/{api,impl,res,viewbinding} └── favorite/{api,impl,res,viewbinding} @@ -60,6 +60,42 @@ Rules enforced by validators ([DEPENDENCY-MATRIX.md](DEPENDENCY-MATRIX.md)): explicitly — do not rely on transitive feature wiring alone. - **`api` stays free of `res/`** under `src/main`. +## Characters feature layering (domain use cases) + +`feature/characters` is the gold sample for **weak presentation↔data coupling** +via domain use cases (F-116 / GH #48). Pattern mirrors `favorite` and applies to +list + detail as well. + +| Layer | Where | Depends on | +|-------|--------|------------| +| Contracts | `core/api` — `MarvelRepository`, `IGetCharactersUseCase`, `IGetCharacterUseCase`, `ICharacter` | network types only | +| Domain impl | `core/impl` — repository + use-case classes; exposed on `CharactersCoreFeature` | `core/api` | +| Presentation | list/detail `impl` ViewModels | **use-case interfaces** from `core/api` (not repository) | +| Paging glue | list `impl` `CharacterPageDataSource` | use case + **ViewModel `CoroutineScope`** | + +Rules demonstrated: + +- ViewModels take use-case interfaces and run work on `viewModelScope`. +- Paging `DataSource` is a thin adapter: no `GlobalScope`, no direct repository calls, + no ownership of a process-global coroutine scope — the ViewModel supplies + `viewModelScope` through the factory. +- Cross-feature code stays on `api` ports; **no `impl`→`impl`**. +- Favorite already followed this pattern (`IGetAllCharactersFavoriteUseCase`, etc.); + list/detail now match via characters `core`. + +Sketch: + +``` +CharactersListViewModel ──IGetCharactersUseCase──► core.api +CharacterDetailViewModel ──IGetCharacterUseCase──► core.api + │ ▲ + │ viewModelScope │ implements + ▼ │ +CharacterPageDataSource ───────────────────────────────┘ + (paging only; no GlobalScope / no repository) +core.impl: GetCharactersUseCase / GetCharacterUseCase → MarvelRepository +``` + ## Package naming Namespace / `packageName` follows the module path under `tools.forma.sample…`: